1use std::path::{Path, PathBuf};
4
5use fallow_config::WorkspaceInfo;
6use fallow_engine::workspace_scope::{WorkspaceScopeError, WorkspaceScopeMode};
7use fallow_output::{DiffIndex, MAX_DIFF_BYTES};
8use fallow_types::path_util::is_absolute_path_any_platform;
9use rustc_hash::FxHashSet;
10
11use crate::{AnalysisOptions, ProgrammaticError};
12
13type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
14
15pub struct ProgrammaticAnalysisContext {
21 pub(crate) root: PathBuf,
22 pub(crate) config_path: Option<PathBuf>,
23 pub(crate) allow_remote_extends: bool,
24 pub(crate) no_cache: bool,
25 pub(crate) threads: usize,
26 pub(crate) pool: rayon::ThreadPool,
27 pub(crate) diff: Option<DiffIndex>,
28 pub(crate) production_override: Option<bool>,
29 pub(crate) changed_since: Option<String>,
30 pub(crate) workspace: Option<Vec<String>>,
31 pub(crate) changed_workspaces: Option<String>,
32 pub(crate) workspace_roots: Option<Vec<PathBuf>>,
33 pub(crate) explain: bool,
34}
35
36pub fn resolve_programmatic_analysis_context(
43 options: &AnalysisOptions,
44) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
45 resolve_programmatic_analysis_context_inner(options, true)
46}
47
48pub fn resolve_programmatic_analysis_context_deferred_workspace(
49 options: &AnalysisOptions,
50) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
51 resolve_programmatic_analysis_context_inner(options, false)
52}
53
54fn resolve_programmatic_analysis_context_inner(
55 options: &AnalysisOptions,
56 resolve_workspace: bool,
57) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
58 validate_analysis_option_shape(options)?;
59 let root = resolve_analysis_root(options.root.as_deref())?;
60 validate_analysis_config_path(options.config_path.as_deref())?;
61 let threads = options.threads.unwrap_or_else(default_threads);
62 let pool = rayon::ThreadPoolBuilder::new()
63 .num_threads(threads)
64 .build()
65 .map_err(|err| {
66 ProgrammaticError::new(format!("failed to build analysis thread pool: {err}"), 2)
67 .with_code("FALLOW_THREAD_POOL_INIT_FAILED")
68 .with_context("analysis.threads")
69 })?;
70 let diff = options
71 .diff_file
72 .as_deref()
73 .map(|path| load_explicit_diff_file(path, &root))
74 .transpose()?;
75 let workspace_roots = if resolve_workspace {
76 resolve_workspace_scope(
77 &root,
78 options.workspace.as_deref(),
79 options.changed_workspaces.as_deref(),
80 )?
81 } else {
82 None
83 };
84 Ok(ProgrammaticAnalysisContext {
85 root,
86 config_path: options.config_path.clone(),
87 allow_remote_extends: options.allow_remote_extends,
88 no_cache: options.no_cache,
89 threads,
90 pool,
91 diff,
92 production_override: options
93 .production_override
94 .or_else(|| options.production.then_some(true)),
95 changed_since: options.changed_since.clone(),
96 workspace: options.workspace.clone(),
97 changed_workspaces: options.changed_workspaces.clone(),
98 workspace_roots,
99 explain: options.explain,
100 })
101}
102
103fn validate_analysis_option_shape(options: &AnalysisOptions) -> ProgrammaticResult<()> {
104 if options.threads == Some(0) {
105 return Err(
106 ProgrammaticError::new("`threads` must be greater than 0", 2)
107 .with_code("FALLOW_INVALID_THREADS")
108 .with_context("analysis.threads"),
109 );
110 }
111 if options.workspace.is_some() && options.changed_workspaces.is_some() {
112 return Err(ProgrammaticError::new(
113 "`workspace` and `changed_workspaces` are mutually exclusive",
114 2,
115 )
116 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
117 .with_context("analysis.workspace"));
118 }
119 Ok(())
120}
121
122fn resolve_analysis_root(root: Option<&Path>) -> ProgrammaticResult<PathBuf> {
123 let root = match root {
124 Some(root) => root.to_path_buf(),
125 None => std::env::current_dir().map_err(|err| {
126 ProgrammaticError::new(
127 format!("failed to resolve current working directory: {err}"),
128 2,
129 )
130 .with_code("FALLOW_CWD_UNAVAILABLE")
131 .with_context("analysis.root")
132 })?,
133 };
134 fallow_engine::validate::validate_root(&root).map_err(|err| {
135 ProgrammaticError::new(err, 2)
136 .with_code("FALLOW_INVALID_ROOT")
137 .with_context("analysis.root")
138 })
139}
140
141fn validate_analysis_config_path(config_path: Option<&Path>) -> ProgrammaticResult<()> {
142 if let Some(config_path) = config_path
143 && !config_path.exists()
144 {
145 return Err(ProgrammaticError::new(
146 format!("config file does not exist: {}", config_path.display()),
147 2,
148 )
149 .with_code("FALLOW_INVALID_CONFIG_PATH")
150 .with_context("analysis.configPath"));
151 }
152 Ok(())
153}
154
155impl ProgrammaticAnalysisContext {
156 pub fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
158 self.pool.install(f)
159 }
160
161 #[must_use]
163 pub fn root(&self) -> &Path {
164 &self.root
165 }
166
167 #[must_use]
169 pub fn config_path(&self) -> &Option<PathBuf> {
170 &self.config_path
171 }
172
173 #[must_use]
175 pub const fn allow_remote_extends(&self) -> bool {
176 self.allow_remote_extends
177 }
178
179 #[must_use]
181 pub const fn no_cache(&self) -> bool {
182 self.no_cache
183 }
184
185 #[must_use]
187 pub const fn threads(&self) -> usize {
188 self.threads
189 }
190
191 #[must_use]
193 pub const fn diff_index(&self) -> Option<&DiffIndex> {
194 self.diff.as_ref()
195 }
196
197 #[must_use]
199 pub const fn production_override(&self) -> Option<bool> {
200 self.production_override
201 }
202
203 #[must_use]
205 pub fn changed_since(&self) -> Option<&str> {
206 self.changed_since.as_deref()
207 }
208
209 #[must_use]
211 pub fn workspace(&self) -> Option<&[String]> {
212 self.workspace.as_deref()
213 }
214
215 #[must_use]
217 pub fn changed_workspaces(&self) -> Option<&str> {
218 self.changed_workspaces.as_deref()
219 }
220
221 #[must_use]
223 pub const fn explain_enabled(&self) -> bool {
224 self.explain
225 }
226}
227
228fn default_threads() -> usize {
229 std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
230}
231
232fn load_explicit_diff_file(path: &Path, root: &Path) -> ProgrammaticResult<DiffIndex> {
233 if path == Path::new("-") {
234 return Err(ProgrammaticError::new(
235 "`diff_file` does not support stdin; pass a file path",
236 2,
237 )
238 .with_code("FALLOW_INVALID_DIFF_FILE")
239 .with_context("analysis.diffFile"));
240 }
241 let abs = if is_absolute_path_any_platform(path) {
242 path.to_path_buf()
243 } else {
244 root.join(path)
245 };
246 let meta = std::fs::metadata(&abs).map_err(|err| {
247 ProgrammaticError::new(
248 format!(
249 "diff file does not exist or cannot be read: {} ({err})",
250 abs.display()
251 ),
252 2,
253 )
254 .with_code("FALLOW_INVALID_DIFF_FILE")
255 .with_context("analysis.diffFile")
256 })?;
257 if !meta.is_file() {
258 return Err(ProgrammaticError::new(
259 format!("diff path is not a file: {}", abs.display()),
260 2,
261 )
262 .with_code("FALLOW_INVALID_DIFF_FILE")
263 .with_context("analysis.diffFile"));
264 }
265 if meta.len() > MAX_DIFF_BYTES {
266 return Err(ProgrammaticError::new(
267 format!(
268 "diff file is {} bytes, above the {MAX_DIFF_BYTES} byte limit: {}",
269 meta.len(),
270 abs.display()
271 ),
272 2,
273 )
274 .with_code("FALLOW_INVALID_DIFF_FILE")
275 .with_context("analysis.diffFile"));
276 }
277 let text = std::fs::read_to_string(&abs).map_err(|err| {
278 ProgrammaticError::new(
279 format!("failed to read diff file {}: {err}", abs.display()),
280 2,
281 )
282 .with_code("FALLOW_INVALID_DIFF_FILE")
283 .with_context("analysis.diffFile")
284 })?;
285 Ok(DiffIndex::from_unified_diff(&text))
286}
287
288pub fn changed_files_for_run(
289 resolved: &ProgrammaticAnalysisContext,
290) -> ProgrammaticResult<Option<FxHashSet<PathBuf>>> {
291 let Some(git_ref) = resolved.changed_since.as_deref() else {
292 return Ok(None);
293 };
294 fallow_engine::changed_files::changed_files(&resolved.root, git_ref)
295 .map(Some)
296 .map_err(|err| {
297 ProgrammaticError::new(
298 format!(
299 "failed to resolve changed files for ref `{git_ref}`: {}",
300 err.describe()
301 ),
302 2,
303 )
304 .with_code("FALLOW_CHANGED_FILES_FAILED")
305 .with_context("analysis.changedSince")
306 })
307}
308
309pub fn workspace_roots_for_session(
310 resolved: &ProgrammaticAnalysisContext,
311 workspaces: &[WorkspaceInfo],
312) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
313 resolve_workspace_scope_from_workspaces(
314 &resolved.root,
315 resolved.workspace.as_deref(),
316 resolved.changed_workspaces.as_deref(),
317 workspaces,
318 )
319}
320
321fn resolve_workspace_scope(
322 root: &Path,
323 workspace: Option<&[String]>,
324 changed_workspaces: Option<&str>,
325) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
326 fallow_engine::workspace_scope::resolve_workspace_scope_roots_for_project(
327 root,
328 workspace,
329 changed_workspaces,
330 )
331 .map_err(map_workspace_scope_error)
332}
333
334fn resolve_workspace_scope_from_workspaces(
335 root: &Path,
336 workspace: Option<&[String]>,
337 changed_workspaces: Option<&str>,
338 workspaces: &[WorkspaceInfo],
339) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
340 fallow_engine::workspace_scope::resolve_workspace_scope_roots(
341 root,
342 workspace,
343 changed_workspaces,
344 workspaces,
345 )
346 .map_err(map_workspace_scope_error)
347}
348
349#[cfg(test)]
350pub fn resolve_workspace_filters(
351 root: &Path,
352 patterns: &[String],
353) -> ProgrammaticResult<Vec<PathBuf>> {
354 fallow_engine::workspace_scope::resolve_workspace_filter_roots_for_project(root, patterns)
355 .map_err(map_workspace_scope_error)
356}
357
358fn map_workspace_scope_error(err: WorkspaceScopeError) -> ProgrammaticError {
359 match err {
360 WorkspaceScopeError::NoWorkspaces {
361 mode,
362 patterns,
363 git_ref,
364 } => map_no_workspaces_error(mode, &patterns, git_ref.as_deref()),
365 WorkspaceScopeError::InvalidPattern { pattern, message } => ProgrammaticError::new(
366 format!("invalid `workspace` pattern '{pattern}': {message}"),
367 2,
368 )
369 .with_code("FALLOW_INVALID_WORKSPACE_PATTERN")
370 .with_context("analysis.workspace"),
371 WorkspaceScopeError::UnmatchedPatterns {
372 patterns,
373 available,
374 } => ProgrammaticError::new(
375 format!(
376 "`workspace` matched no workspace for pattern{}: {}. Available: {available}",
377 if patterns.len() == 1 { "" } else { "s" },
378 quote_owned_patterns(&patterns),
379 ),
380 2,
381 )
382 .with_code("FALLOW_WORKSPACE_PATTERN_UNMATCHED")
383 .with_context("analysis.workspace"),
384 WorkspaceScopeError::EmptyAfterExclusions { .. } => {
385 ProgrammaticError::new("`workspace` excluded every discovered workspace", 2)
386 .with_code("FALLOW_WORKSPACE_SCOPE_EMPTY")
387 .with_context("analysis.workspace")
388 }
389 WorkspaceScopeError::ChangedWorkspacesFailed { git_ref, message } => {
390 ProgrammaticError::new(
391 format!("failed to resolve changed workspaces for ref `{git_ref}`: {message}"),
392 2,
393 )
394 .with_code("FALLOW_CHANGED_WORKSPACES_FAILED")
395 .with_context("analysis.changedWorkspaces")
396 }
397 WorkspaceScopeError::MutuallyExclusive => ProgrammaticError::new(
398 "`workspace` and `changed_workspaces` are mutually exclusive",
399 2,
400 )
401 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
402 .with_context("analysis.workspace"),
403 }
404}
405
406fn map_no_workspaces_error(
407 mode: WorkspaceScopeMode,
408 patterns: &[String],
409 git_ref: Option<&str>,
410) -> ProgrammaticError {
411 match mode {
412 WorkspaceScopeMode::Workspace => ProgrammaticError::new(
413 format!(
414 "`workspace` {} specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\".",
415 quote_owned_patterns(patterns)
416 ),
417 2,
418 )
419 .with_code("FALLOW_WORKSPACES_NOT_FOUND")
420 .with_context("analysis.workspace"),
421 WorkspaceScopeMode::ChangedWorkspaces => {
422 let git_ref = git_ref.unwrap_or_default();
423 ProgrammaticError::new(
424 format!(
425 "`changed_workspaces` '{git_ref}' specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\"."
426 ),
427 2,
428 )
429 .with_code("FALLOW_WORKSPACES_NOT_FOUND")
430 .with_context("analysis.changedWorkspaces")
431 }
432 }
433}
434
435fn quote_owned_patterns(patterns: &[String]) -> String {
436 patterns
437 .iter()
438 .map(|pattern| format!("'{pattern}'"))
439 .collect::<Vec<_>>()
440 .join(", ")
441}