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