Skip to main content

fallow_api/
analysis_context.rs

1//! Shared programmatic analysis context resolution.
2
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, Mutex, OnceLock};
6
7use fallow_config::{ResolvedConfig, WorkspaceInfo};
8use fallow_engine::change_scope::{
9    ChangeScope, ChangeScopeOwner, ChangeScopeRequest, PackageBaselineCache,
10};
11use fallow_engine::workspace_scope::{WorkspaceScopeError, WorkspaceScopeMode};
12use fallow_output::{DiffIndex, MAX_DIFF_BYTES, RequestName, RequestOutcome, RequestOutcomes};
13use fallow_types::path_util::is_absolute_path_any_platform;
14use rustc_hash::FxHashSet;
15
16use crate::{AnalysisOptions, ProgrammaticError};
17
18type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
19
20/// Resolved common programmatic analysis context.
21///
22/// This owns validation, root/config/diff resolution, production overrides,
23/// the workspace selection, and the per-call thread pool shared by programmatic
24/// analysis families. API runtimes and engine-backed runners use it directly.
25///
26/// The workspace selection resolves against the workspaces of the analysis
27/// session (see `workspace_roots_for_session`). These workspaces include the
28/// `workspaces.patterns` of the loaded config, so the context does not resolve
29/// the selection before the config loads.
30pub struct ProgrammaticAnalysisContext {
31    pub(crate) root: PathBuf,
32    pub(crate) config_path: Option<PathBuf>,
33    pub(crate) allow_remote_extends: bool,
34    pub(crate) no_cache: bool,
35    pub(crate) threads: usize,
36    pub(crate) pool: rayon::ThreadPool,
37    pub(crate) diff: Option<DiffIndex>,
38    /// What became of the diff request, for the envelope's `request_outcomes`.
39    pub(crate) diff_request: Option<RequestOutcome>,
40    pub(crate) production_override: Option<bool>,
41    /// The changed-since ref the call narrows by: the caller's own, or the
42    /// ambient one when it resolved. `None` when an ambient ref stood down.
43    pub(crate) changed_since: Option<String>,
44    /// What became of the changed-since request, set once it resolved or
45    /// stood down.
46    pub(crate) changed_since_request: OnceLock<RequestOutcome>,
47    /// The changed files of the resolved ref, normalized like the CLI's.
48    pub(crate) changed_since_files: OnceLock<FxHashSet<PathBuf>>,
49    /// Who owns the change scope of the call's analyses. Audit owns it, so
50    /// its sections never read `workspaces.changedSince`.
51    pub(crate) change_scope_owner: ChangeScopeOwner,
52    /// The caller turned `workspaces.changedSince` off for this call.
53    pub(crate) no_package_baselines: bool,
54    /// The package map as the call's first analysis resolved it. Later
55    /// analyses of the call reuse it, and its outcome is the call's
56    /// `package-baselines` request outcome.
57    pub(crate) package_baselines: PackageBaselineCache,
58    /// The changed files the call's analyses kept, over every analysis that
59    /// measured: the `scope_size` of the `changed-since` entry.
60    pub(crate) changed_since_analyzed: Mutex<Option<FxHashSet<PathBuf>>>,
61    pub(crate) workspace: Option<Vec<String>>,
62    pub(crate) changed_workspaces: Option<String>,
63    pub(crate) explain: bool,
64    pub(crate) cancellation: Option<Arc<AtomicBool>>,
65}
66
67/// Resolve common programmatic analysis options once for a concrete runtime.
68///
69/// # Errors
70///
71/// Returns a structured programmatic error for invalid roots, configs, thread
72/// counts, workspace scopes, or explicit diff files.
73pub fn resolve_programmatic_analysis_context(
74    options: &AnalysisOptions,
75) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
76    validate_analysis_option_shape(options)?;
77    let root = resolve_analysis_root(options.root.as_deref())?;
78    validate_analysis_config_path(options.config_path.as_deref())?;
79    let threads = options.threads.unwrap_or_else(default_threads);
80    let pool = fallow_engine::thread_pool::worker_pool_builder(threads)
81        .build()
82        .map_err(|err| {
83            ProgrammaticError::new(format!("failed to build analysis thread pool: {err}"), 2)
84                .with_code("FALLOW_THREAD_POOL_INIT_FAILED")
85                .with_context("analysis.threads")
86        })?;
87    let (diff, diff_request) = resolve_diff(options, &root)?;
88    let changed_since_request = OnceLock::new();
89    let changed_since_files = OnceLock::new();
90    let changed_since =
91        resolve_changed_since(options, &root, &changed_since_request, &changed_since_files);
92    Ok(ProgrammaticAnalysisContext {
93        root,
94        config_path: options.config_path.clone(),
95        allow_remote_extends: options.allow_remote_extends,
96        no_cache: options.no_cache,
97        threads,
98        pool,
99        diff,
100        diff_request,
101        production_override: options
102            .production_override
103            .or_else(|| options.production.then_some(true)),
104        changed_since,
105        changed_since_request,
106        changed_since_files,
107        change_scope_owner: ChangeScopeOwner::Run,
108        no_package_baselines: options.no_package_baselines,
109        package_baselines: PackageBaselineCache::new(),
110        changed_since_analyzed: Mutex::new(None),
111        workspace: options.workspace.clone(),
112        changed_workspaces: options.changed_workspaces.clone(),
113        explain: options.explain,
114        cancellation: options.cancellation.clone(),
115    })
116}
117
118fn validate_analysis_option_shape(options: &AnalysisOptions) -> ProgrammaticResult<()> {
119    if options.threads == Some(0) {
120        return Err(
121            ProgrammaticError::new("`threads` must be greater than 0", 2)
122                .with_code("FALLOW_INVALID_THREADS")
123                .with_context("analysis.threads"),
124        );
125    }
126    if options.workspace.is_some() && options.changed_workspaces.is_some() {
127        return Err(ProgrammaticError::new(
128            "`workspace` and `changed_workspaces` are mutually exclusive",
129            2,
130        )
131        .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
132        .with_context("analysis.workspace"));
133    }
134    Ok(())
135}
136
137pub fn resolve_analysis_root(root: Option<&Path>) -> ProgrammaticResult<PathBuf> {
138    let root = match root {
139        Some(root) => root.to_path_buf(),
140        None => std::env::current_dir().map_err(|err| {
141            ProgrammaticError::new(
142                format!("failed to resolve current working directory: {err}"),
143                2,
144            )
145            .with_code("FALLOW_CWD_UNAVAILABLE")
146            .with_context("analysis.root")
147        })?,
148    };
149    fallow_engine::validate::validate_root(&root).map_err(|err| {
150        ProgrammaticError::new(err, 2)
151            .with_code("FALLOW_INVALID_ROOT")
152            .with_context("analysis.root")
153    })
154}
155
156pub fn validate_analysis_config_path(config_path: Option<&Path>) -> ProgrammaticResult<()> {
157    if let Some(config_path) = config_path
158        && !config_path.exists()
159    {
160        return Err(ProgrammaticError::new(
161            format!("config file does not exist: {}", config_path.display()),
162            2,
163        )
164        .with_code("FALLOW_INVALID_CONFIG_PATH")
165        .with_context("analysis.configPath"));
166    }
167    Ok(())
168}
169
170impl ProgrammaticAnalysisContext {
171    /// Run work inside the per-call Rayon pool.
172    pub fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
173        self.pool.install(f)
174    }
175
176    /// Resolved analysis root.
177    #[must_use]
178    pub fn root(&self) -> &Path {
179        &self.root
180    }
181
182    /// Config path supplied by the caller, if any.
183    #[must_use]
184    pub fn config_path(&self) -> &Option<PathBuf> {
185        &self.config_path
186    }
187
188    /// Whether this call permits remote config inheritance.
189    #[must_use]
190    pub const fn allow_remote_extends(&self) -> bool {
191        self.allow_remote_extends
192    }
193
194    /// Whether parser cache use is disabled for this call.
195    #[must_use]
196    pub const fn no_cache(&self) -> bool {
197        self.no_cache
198    }
199
200    /// Effective parser thread count for this call.
201    #[must_use]
202    pub const fn threads(&self) -> usize {
203        self.threads
204    }
205
206    /// Parsed diff for this call, explicit or ambient, if one applied.
207    #[must_use]
208    pub const fn diff_index(&self) -> Option<&DiffIndex> {
209        self.diff.as_ref()
210    }
211
212    /// The call's `request_outcomes`, or `None` when it was asked for nothing.
213    ///
214    /// Carries the `diff-filter` entry, which is the one request this context
215    /// resolves and can stand down. Same object as the CLI publishes for the
216    /// same diff.
217    #[must_use]
218    pub fn request_outcomes(&self) -> Option<RequestOutcomes> {
219        let mut requests = RequestOutcomes::new();
220        requests.insert_if(RequestName::ChangedSince, self.changed_since_outcome());
221        requests.insert_if(RequestName::DiffFilter, self.diff_request.clone());
222        requests.insert_if(
223            RequestName::PackageBaselines,
224            self.package_baselines.request_outcome(),
225        );
226        requests.into_option()
227    }
228
229    /// The `changed-since` entry, with the measured scope when the ref applied
230    /// and an analysis measured it, as the CLI publishes it.
231    fn changed_since_outcome(&self) -> Option<RequestOutcome> {
232        let outcome = self.changed_since_request.get()?.clone();
233        let size = self
234            .changed_since_analyzed
235            .lock()
236            .ok()
237            .and_then(|analyzed| analyzed.as_ref().map(|files| files.len() as u64));
238        Some(match size {
239            Some(size) if outcome.status == fallow_output::RequestStatus::Applied => {
240                RequestOutcome {
241                    scope_size: Some(size),
242                    ..outcome
243                }
244            }
245            _ => outcome,
246        })
247    }
248
249    /// Add the changed files an analysis kept to the call's analyzed changed
250    /// files. Does nothing when no ref resolved.
251    pub(crate) fn measure_changed_since_scope<'a>(
252        &self,
253        analyzed: impl IntoIterator<Item = &'a Path>,
254    ) {
255        let Some(changed) = self.changed_since_files.get() else {
256            return;
257        };
258        let Ok(mut union) = self.changed_since_analyzed.lock() else {
259            return;
260        };
261        union.get_or_insert_with(FxHashSet::default).extend(
262            analyzed
263                .into_iter()
264                .map(dunce::simplified)
265                .filter(|path| changed.contains(*path))
266                .map(Path::to_path_buf),
267        );
268    }
269
270    /// Record the resolved changed files of the call's ref, and the `applied`
271    /// entry, once.
272    fn record_changed_since_applied(&self, git_ref: &str, files: &FxHashSet<PathBuf>) {
273        let _ = self.changed_since_files.set(
274            files
275                .iter()
276                .map(|path| dunce::simplified(path).to_path_buf())
277                .collect(),
278        );
279        let _ = self
280            .changed_since_request
281            .set(RequestOutcome::applied(RequestName::ChangedSince, git_ref));
282    }
283
284    /// Record that an engine runner narrowed by the call's ref, with the
285    /// changed files it kept. For a runner that resolves the ref itself.
286    pub(crate) fn record_changed_since_from_runner(&self, kept: Option<&[PathBuf]>) {
287        let (Some(git_ref), Some(kept)) = (self.changed_since.as_deref(), kept) else {
288            return;
289        };
290        if self.changed_since_files.get().is_none() {
291            let files: FxHashSet<PathBuf> = kept.iter().cloned().collect();
292            self.record_changed_since_applied(git_ref, &files);
293        }
294        self.measure_changed_since_scope(kept.iter().map(PathBuf::as_path));
295    }
296
297    /// Explicit production override supplied by the caller.
298    #[must_use]
299    pub const fn production_override(&self) -> Option<bool> {
300        self.production_override
301    }
302
303    /// Git ref used to scope changed files.
304    #[must_use]
305    pub fn changed_since(&self) -> Option<&str> {
306        self.changed_since.as_deref()
307    }
308
309    /// Hand the change scope of this call's analyses to the caller.
310    #[must_use]
311    pub(crate) const fn with_change_scope_owner(mut self, owner: ChangeScopeOwner) -> Self {
312        self.change_scope_owner = owner;
313        self
314    }
315
316    /// Resolve the change scope of one analysis in this call.
317    ///
318    /// `files` is the changed-file set of the analysis: the call's resolved
319    /// ref, or a set that the caller supplied. A requested ref that stood
320    /// down still suppresses the package map, as on the CLI.
321    pub(crate) fn change_scope(
322        &self,
323        files: Option<&FxHashSet<PathBuf>>,
324        config: &ResolvedConfig,
325        workspaces: &[WorkspaceInfo],
326    ) -> ProgrammaticResult<ChangeScope> {
327        let request = ChangeScopeRequest {
328            owner: self.change_scope_owner,
329            global_ref: self.changed_since.is_some() || self.changed_since_request.get().is_some(),
330            files,
331            cache: Some(&self.package_baselines),
332            no_package_baselines: self.no_package_baselines,
333        };
334        ChangeScope::resolve(request, config, workspaces).map_err(|err| {
335            ProgrammaticError::new(format!("workspace baseline error: {err}"), 2)
336                .with_code("FALLOW_PACKAGE_BASELINE_FAILED")
337                .with_context("analysis.workspaces.changedSince")
338        })
339    }
340
341    /// Workspace filter patterns supplied by the caller.
342    #[must_use]
343    pub fn workspace(&self) -> Option<&[String]> {
344        self.workspace.as_deref()
345    }
346
347    /// Git ref used to scope changed workspaces.
348    #[must_use]
349    pub fn changed_workspaces(&self) -> Option<&str> {
350        self.changed_workspaces.as_deref()
351    }
352
353    /// Whether API JSON should include explanatory metadata.
354    #[must_use]
355    pub const fn explain_enabled(&self) -> bool {
356        self.explain
357    }
358
359    /// The caller's cancellation token for this analysis, if it supplied one.
360    #[must_use]
361    pub fn cancellation(&self) -> Option<&Arc<AtomicBool>> {
362        self.cancellation.as_ref()
363    }
364
365    /// Whether the caller has asked this analysis to stop.
366    #[must_use]
367    pub fn is_cancelled(&self) -> bool {
368        self.cancellation
369            .as_ref()
370            .is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
371    }
372
373    /// Stop the analysis at a stage boundary once the caller has cancelled it.
374    ///
375    /// `stage` names the work that has not been started, so the error says how
376    /// far the run got rather than only that it was stopped.
377    ///
378    /// # Errors
379    ///
380    /// Returns a `FALLOW_CANCELLED` programmatic error when the caller's token
381    /// is set. Cancellation is always an error, never an empty success: an
382    /// empty report reads downstream as a clean project.
383    pub fn ensure_not_cancelled(&self, stage: &str) -> ProgrammaticResult<()> {
384        if self.is_cancelled() {
385            return Err(cancelled_error(stage));
386        }
387        Ok(())
388    }
389}
390
391/// Stop before any work starts when the caller's token is already set.
392///
393/// Runtimes that never build a [`ProgrammaticAnalysisContext`] read the token
394/// straight off the options with this.
395///
396/// # Errors
397///
398/// Returns a `FALLOW_CANCELLED` programmatic error when the token is set.
399pub fn ensure_options_not_cancelled(
400    options: &AnalysisOptions,
401    stage: &str,
402) -> ProgrammaticResult<()> {
403    if options
404        .cancellation
405        .as_ref()
406        .is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
407    {
408        return Err(cancelled_error(stage));
409    }
410    Ok(())
411}
412
413/// The single `FALLOW_CANCELLED` error shape for the programmatic API.
414///
415/// `stage` names the work the run never started, so the error says how far it
416/// got and not only that it stopped.
417#[must_use]
418pub fn cancelled_error(stage: &str) -> ProgrammaticError {
419    cancelled_error_message(&format!("analysis was cancelled before {stage}"))
420}
421
422/// A `FALLOW_CANCELLED` error carrying a message a lower layer already built.
423#[must_use]
424pub fn cancelled_error_message(message: &str) -> ProgrammaticError {
425    ProgrammaticError::new(message, 2)
426        .with_code("FALLOW_CANCELLED")
427        .with_context("analysis.cancellation")
428}
429
430fn default_threads() -> usize {
431    std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
432}
433
434/// Resolve the call's diff from its two sources, which fail differently.
435///
436/// An explicit `diff_file` is the caller's own argument, so a bad file is a
437/// `FALLOW_INVALID_DIFF_FILE` error. An ambient `FALLOW_DIFF_FILE` comes from
438/// the environment the caller inherited, so a bad file stands down: no diff,
439/// full scope, and a `not-applied` outcome with the CLI's reason token and
440/// sentence. The source decides the behavior, never the text of an error.
441fn resolve_diff(
442    options: &AnalysisOptions,
443    root: &Path,
444) -> ProgrammaticResult<(Option<DiffIndex>, Option<RequestOutcome>)> {
445    if let Some(path) = options.diff_file.as_deref() {
446        let index = load_explicit_diff_file(path, root)?;
447        let request = diff_applied(format!("diffFile {}", path.display()), &index);
448        return Ok((Some(index), Some(request)));
449    }
450    let Some(path) = options.ambient_diff_file.as_deref() else {
451        return Ok((None, None));
452    };
453    Ok(load_ambient_diff_file(path, root))
454}
455
456/// Load and place an ambient diff the way the CLI loads `$FALLOW_DIFF_FILE`,
457/// with the same label, so both routes publish the same outcome object.
458fn load_ambient_diff_file(path: &Path, root: &Path) -> (Option<DiffIndex>, Option<RequestOutcome>) {
459    let abs = if path.is_absolute() {
460        path.to_path_buf()
461    } else {
462        root.join(path)
463    };
464    let label = format!("$FALLOW_DIFF_FILE {}", abs.display());
465    let placed = fallow_engine::diff_source::read_diff_file(&abs, &label).and_then(|text| {
466        fallow_engine::diff_source::place_diff(
467            DiffIndex::from_unified_diff(&text),
468            root,
469            &fallow_engine::diff_source::diff_base_candidates(root),
470            &label,
471        )
472    });
473    match placed {
474        Ok(index) => {
475            let request = diff_applied(label, &index);
476            (Some(index), Some(request))
477        }
478        Err(stand_down) => {
479            let (reason, message) = stand_down.into_parts();
480            let request =
481                RequestOutcome::not_applied(RequestName::DiffFilter, label, reason, message);
482            (None, Some(request))
483        }
484    }
485}
486
487/// An applied diff filter, sized in added lines like the CLI's.
488fn diff_applied(label: String, index: &DiffIndex) -> RequestOutcome {
489    RequestOutcome::applied_with_scope_size(
490        RequestName::DiffFilter,
491        label,
492        index.added_line_count() as u64,
493    )
494}
495
496fn load_explicit_diff_file(path: &Path, root: &Path) -> ProgrammaticResult<DiffIndex> {
497    if path == Path::new("-") {
498        return Err(ProgrammaticError::new(
499            "`diff_file` does not support stdin; pass a file path",
500            2,
501        )
502        .with_code("FALLOW_INVALID_DIFF_FILE")
503        .with_context("analysis.diffFile"));
504    }
505    let abs = if is_absolute_path_any_platform(path) {
506        path.to_path_buf()
507    } else {
508        root.join(path)
509    };
510    let meta = std::fs::metadata(&abs).map_err(|err| {
511        ProgrammaticError::new(
512            format!(
513                "diff file does not exist or cannot be read: {} ({err})",
514                abs.display()
515            ),
516            2,
517        )
518        .with_code("FALLOW_INVALID_DIFF_FILE")
519        .with_context("analysis.diffFile")
520    })?;
521    if !meta.is_file() {
522        return Err(ProgrammaticError::new(
523            format!("diff path is not a file: {}", abs.display()),
524            2,
525        )
526        .with_code("FALLOW_INVALID_DIFF_FILE")
527        .with_context("analysis.diffFile"));
528    }
529    if meta.len() > MAX_DIFF_BYTES {
530        return Err(ProgrammaticError::new(
531            format!(
532                "diff file is {} bytes, above the {MAX_DIFF_BYTES} byte limit: {}",
533                meta.len(),
534                abs.display()
535            ),
536            2,
537        )
538        .with_code("FALLOW_INVALID_DIFF_FILE")
539        .with_context("analysis.diffFile"));
540    }
541    let text = std::fs::read_to_string(&abs).map_err(|err| {
542        ProgrammaticError::new(
543            format!("failed to read diff file {}: {err}", abs.display()),
544            2,
545        )
546        .with_code("FALLOW_INVALID_DIFF_FILE")
547        .with_context("analysis.diffFile")
548    })?;
549    Ok(DiffIndex::from_unified_diff(&text))
550}
551
552/// Resolve the call's changed-since ref once, when it comes from the
553/// environment.
554///
555/// The two sources fail differently, like the two diff sources. An explicit
556/// `changed_since` is the caller's own argument, so a ref that does not
557/// resolve fails the call later, in [`changed_files_for_run`]. An ambient
558/// `FALLOW_CHANGED_SINCE` comes from the environment the caller inherited, so
559/// a ref that does not resolve stands down here: the call runs at full scope
560/// and publishes `not-applied` with the CLI's reason token and sentence.
561fn resolve_changed_since(
562    options: &AnalysisOptions,
563    root: &Path,
564    request: &OnceLock<RequestOutcome>,
565    files: &OnceLock<FxHashSet<PathBuf>>,
566) -> Option<String> {
567    if let Some(git_ref) = options.changed_since.as_deref() {
568        return Some(git_ref.to_owned());
569    }
570    let git_ref = options.ambient_changed_since.as_deref()?;
571    match fallow_engine::changed_files::changed_files(root, git_ref) {
572        Ok(changed) => {
573            let _ = files.set(
574                changed
575                    .iter()
576                    .map(|path| dunce::simplified(path).to_path_buf())
577                    .collect(),
578            );
579            let _ = request.set(RequestOutcome::applied(RequestName::ChangedSince, git_ref));
580            Some(git_ref.to_owned())
581        }
582        Err(err) => {
583            let _ = request.set(RequestOutcome::not_applied(
584                RequestName::ChangedSince,
585                git_ref,
586                err.reason(),
587                err.changed_since_message(git_ref),
588            ));
589            None
590        }
591    }
592}
593
594pub fn changed_files_for_run(
595    resolved: &ProgrammaticAnalysisContext,
596) -> ProgrammaticResult<Option<FxHashSet<PathBuf>>> {
597    let Some(git_ref) = resolved.changed_since.as_deref() else {
598        return Ok(None);
599    };
600    fallow_engine::changed_files::changed_files(&resolved.root, git_ref)
601        .inspect(|files| resolved.record_changed_since_applied(git_ref, files))
602        .map(Some)
603        .map_err(|err| {
604            ProgrammaticError::new(
605                format!(
606                    "failed to resolve changed files for ref `{git_ref}`: {}",
607                    err.describe()
608                ),
609                2,
610            )
611            .with_code("FALLOW_CHANGED_FILES_FAILED")
612            .with_context("analysis.changedSince")
613        })
614}
615
616/// Check the workspace selection of the call against the session workspaces,
617/// for a route that reads the whole project but still rejects a selection
618/// that names no workspace.
619pub fn validate_workspace_selection(
620    resolved: &ProgrammaticAnalysisContext,
621    workspaces: &[WorkspaceInfo],
622) -> ProgrammaticResult<()> {
623    workspace_roots_for_session(resolved, workspaces).map(|_| ())
624}
625
626pub fn workspace_roots_for_session(
627    resolved: &ProgrammaticAnalysisContext,
628    workspaces: &[WorkspaceInfo],
629) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
630    resolve_workspace_scope_from_workspaces(
631        &resolved.root,
632        resolved.workspace.as_deref(),
633        resolved.changed_workspaces.as_deref(),
634        workspaces,
635    )
636}
637
638fn resolve_workspace_scope_from_workspaces(
639    root: &Path,
640    workspace: Option<&[String]>,
641    changed_workspaces: Option<&str>,
642    workspaces: &[WorkspaceInfo],
643) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
644    fallow_engine::workspace_scope::resolve_workspace_scope_roots(
645        root,
646        workspace,
647        changed_workspaces,
648        workspaces,
649    )
650    .map_err(map_workspace_scope_error)
651}
652
653fn map_workspace_scope_error(err: WorkspaceScopeError) -> ProgrammaticError {
654    match err {
655        WorkspaceScopeError::NoWorkspaces {
656            mode,
657            patterns,
658            git_ref,
659        } => map_no_workspaces_error(mode, &patterns, git_ref.as_deref()),
660        WorkspaceScopeError::InvalidPattern { pattern, message } => ProgrammaticError::new(
661            format!("invalid `workspace` pattern '{pattern}': {message}"),
662            2,
663        )
664        .with_code("FALLOW_INVALID_WORKSPACE_PATTERN")
665        .with_context("analysis.workspace"),
666        WorkspaceScopeError::UnmatchedPatterns {
667            patterns,
668            available,
669        } => ProgrammaticError::new(
670            format!(
671                "`workspace` matched no workspace for pattern{}: {}. Available: {available}",
672                if patterns.len() == 1 { "" } else { "s" },
673                quote_owned_patterns(&patterns),
674            ),
675            2,
676        )
677        .with_code("FALLOW_WORKSPACE_PATTERN_UNMATCHED")
678        .with_context("analysis.workspace"),
679        WorkspaceScopeError::EmptyAfterExclusions { .. } => {
680            ProgrammaticError::new("`workspace` excluded every discovered workspace", 2)
681                .with_code("FALLOW_WORKSPACE_SCOPE_EMPTY")
682                .with_context("analysis.workspace")
683        }
684        WorkspaceScopeError::ChangedWorkspacesFailed { git_ref, message } => {
685            ProgrammaticError::new(
686                format!("failed to resolve changed workspaces for ref `{git_ref}`: {message}"),
687                2,
688            )
689            .with_code("FALLOW_CHANGED_WORKSPACES_FAILED")
690            .with_context("analysis.changedWorkspaces")
691        }
692        WorkspaceScopeError::MutuallyExclusive => ProgrammaticError::new(
693            "`workspace` and `changed_workspaces` are mutually exclusive",
694            2,
695        )
696        .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
697        .with_context("analysis.workspace"),
698    }
699}
700
701fn map_no_workspaces_error(
702    mode: WorkspaceScopeMode,
703    patterns: &[String],
704    git_ref: Option<&str>,
705) -> ProgrammaticError {
706    match mode {
707        WorkspaceScopeMode::Workspace => ProgrammaticError::new(
708            format!(
709                "`workspace` {} specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, tsconfig.json has \"references\", or the project sets \"workspaces.patterns\" in the fallow config.",
710                quote_owned_patterns(patterns)
711            ),
712            2,
713        )
714        .with_code("FALLOW_WORKSPACES_NOT_FOUND")
715        .with_context("analysis.workspace"),
716        WorkspaceScopeMode::ChangedWorkspaces => {
717            let git_ref = git_ref.unwrap_or_default();
718            ProgrammaticError::new(
719                format!(
720                    "`changed_workspaces` '{git_ref}' specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, tsconfig.json has \"references\", or the project sets \"workspaces.patterns\" in the fallow config."
721                ),
722                2,
723            )
724            .with_code("FALLOW_WORKSPACES_NOT_FOUND")
725            .with_context("analysis.changedWorkspaces")
726        }
727    }
728}
729
730fn quote_owned_patterns(patterns: &[String]) -> String {
731    patterns
732        .iter()
733        .map(|pattern| format!("'{pattern}'"))
734        .collect::<Vec<_>>()
735        .join(", ")
736}
737
738#[cfg(test)]
739mod tests {
740    use std::process::Command;
741
742    use crate::AnalysisOptions;
743
744    const STACK_PROBE_ENV: &str = "FALLOW_API_STACK_PROBE_CHILD";
745    const STACK_PROBE_TEST: &str =
746        "analysis_context::tests::programmatic_pool_survives_deep_worker_stack_probe";
747
748    // A stack overflow aborts the whole process, so the probe re-runs this
749    // test binary as a child and asserts on its exit status; the same pattern
750    // guards the CLI global pool in crates/cli/src/rayon_pool.rs. The child
751    // drops RUST_MIN_STACK (pinned to 16 MiB in .cargo/config.toml, and
752    // inherited by default-sized rayon workers) so the probe still fails if
753    // the pool loses its explicit stack_size.
754    #[test]
755    fn programmatic_pool_survives_deep_worker_stack_probe() {
756        if std::env::var_os(STACK_PROBE_ENV).is_some() {
757            run_stack_probe_child();
758            return;
759        }
760
761        let current_exe = std::env::current_exe().expect("current test binary should be known");
762        let output = Command::new(current_exe)
763            .arg("--exact")
764            .arg(STACK_PROBE_TEST)
765            .arg("--nocapture")
766            .env(STACK_PROBE_ENV, "1")
767            .env_remove("RUST_MIN_STACK")
768            .output()
769            .expect("stack probe child should start");
770
771        assert!(
772            output.status.success(),
773            "stack probe child failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
774            output.status.code(),
775            String::from_utf8_lossy(&output.stdout),
776            String::from_utf8_lossy(&output.stderr)
777        );
778    }
779
780    fn run_stack_probe_child() {
781        let root = tempfile::tempdir().expect("stack probe needs a temp analysis root");
782        let options = AnalysisOptions {
783            root: Some(root.path().to_path_buf()),
784            threads: Some(1),
785            ..AnalysisOptions::default()
786        };
787        let context = super::resolve_programmatic_analysis_context(&options)
788            .expect("stack probe context should resolve");
789        assert_eq!(context.install(|| consume_stack(5_000)), 5_000);
790    }
791
792    #[inline(never)]
793    fn consume_stack(depth: usize) -> usize {
794        let frame = [0_u8; 2048];
795        std::hint::black_box(&frame);
796        if depth == 0 {
797            usize::from(frame[0])
798        } else {
799            1 + consume_stack(depth - 1)
800        }
801    }
802
803    #[test]
804    fn no_workspaces_error_names_the_config_key() {
805        for (mode, git_ref) in [
806            (super::WorkspaceScopeMode::Workspace, None),
807            (super::WorkspaceScopeMode::ChangedWorkspaces, Some("main")),
808        ] {
809            let err = super::map_no_workspaces_error(mode, &["web".to_owned()], git_ref);
810            assert!(
811                err.message
812                    .contains("\"workspaces.patterns\" in the fallow config"),
813                "{}",
814                err.message
815            );
816        }
817    }
818}