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. A readable patch from either source stands down when its paths
441/// cannot be placed. An explicit source remains authoritative in that case.
442fn resolve_diff(
443    options: &AnalysisOptions,
444    root: &Path,
445) -> ProgrammaticResult<(Option<DiffIndex>, Option<RequestOutcome>)> {
446    if let Some(path) = options.diff_file.as_deref() {
447        let index = load_explicit_diff_file(path, root)?;
448        let label = format!("diffFile {}", path.display());
449        return Ok(
450            match fallow_engine::diff_source::place_diff(
451                index,
452                root,
453                &fallow_engine::diff_source::diff_base_candidates(root),
454                &label,
455            ) {
456                Ok(index) => {
457                    let request = diff_applied(label, &index);
458                    (Some(index), Some(request))
459                }
460                Err(stand_down) => {
461                    let (reason, message) = stand_down.into_parts();
462                    let request = RequestOutcome::not_applied(
463                        RequestName::DiffFilter,
464                        label,
465                        reason,
466                        message,
467                    );
468                    (None, Some(request))
469                }
470            },
471        );
472    }
473    let Some(path) = options.ambient_diff_file.as_deref() else {
474        return Ok((None, None));
475    };
476    Ok(load_ambient_diff_file(path, root))
477}
478
479/// Load and place an ambient diff the way the CLI loads `$FALLOW_DIFF_FILE`,
480/// with the same label, so both routes publish the same outcome object.
481fn load_ambient_diff_file(path: &Path, root: &Path) -> (Option<DiffIndex>, Option<RequestOutcome>) {
482    let abs = if path.is_absolute() {
483        path.to_path_buf()
484    } else {
485        root.join(path)
486    };
487    let label = format!("$FALLOW_DIFF_FILE {}", abs.display());
488    let placed = fallow_engine::diff_source::read_diff_file(&abs, &label).and_then(|text| {
489        fallow_engine::diff_source::place_diff(
490            DiffIndex::from_unified_diff(&text),
491            root,
492            &fallow_engine::diff_source::diff_base_candidates(root),
493            &label,
494        )
495    });
496    match placed {
497        Ok(index) => {
498            let request = diff_applied(label, &index);
499            (Some(index), Some(request))
500        }
501        Err(stand_down) => {
502            let (reason, message) = stand_down.into_parts();
503            let request =
504                RequestOutcome::not_applied(RequestName::DiffFilter, label, reason, message);
505            (None, Some(request))
506        }
507    }
508}
509
510/// An applied diff filter, sized in added lines like the CLI's.
511fn diff_applied(label: String, index: &DiffIndex) -> RequestOutcome {
512    RequestOutcome::applied_with_scope_size(
513        RequestName::DiffFilter,
514        label,
515        index.added_line_count() as u64,
516    )
517}
518
519fn load_explicit_diff_file(path: &Path, root: &Path) -> ProgrammaticResult<DiffIndex> {
520    if path == Path::new("-") {
521        return Err(ProgrammaticError::new(
522            "`diff_file` does not support stdin; pass a file path",
523            2,
524        )
525        .with_code("FALLOW_INVALID_DIFF_FILE")
526        .with_context("analysis.diffFile"));
527    }
528    let abs = if is_absolute_path_any_platform(path) {
529        path.to_path_buf()
530    } else {
531        root.join(path)
532    };
533    let meta = std::fs::metadata(&abs).map_err(|err| {
534        ProgrammaticError::new(
535            format!(
536                "diff file does not exist or cannot be read: {} ({err})",
537                abs.display()
538            ),
539            2,
540        )
541        .with_code("FALLOW_INVALID_DIFF_FILE")
542        .with_context("analysis.diffFile")
543    })?;
544    if !meta.is_file() {
545        return Err(ProgrammaticError::new(
546            format!("diff path is not a file: {}", abs.display()),
547            2,
548        )
549        .with_code("FALLOW_INVALID_DIFF_FILE")
550        .with_context("analysis.diffFile"));
551    }
552    if meta.len() > MAX_DIFF_BYTES {
553        return Err(ProgrammaticError::new(
554            format!(
555                "diff file is {} bytes, above the {MAX_DIFF_BYTES} byte limit: {}",
556                meta.len(),
557                abs.display()
558            ),
559            2,
560        )
561        .with_code("FALLOW_INVALID_DIFF_FILE")
562        .with_context("analysis.diffFile"));
563    }
564    let text = std::fs::read_to_string(&abs).map_err(|err| {
565        ProgrammaticError::new(
566            format!("failed to read diff file {}: {err}", abs.display()),
567            2,
568        )
569        .with_code("FALLOW_INVALID_DIFF_FILE")
570        .with_context("analysis.diffFile")
571    })?;
572    Ok(DiffIndex::from_unified_diff(&text))
573}
574
575/// Resolve the call's changed-since ref once, when it comes from the
576/// environment.
577///
578/// The two sources fail differently, like the two diff sources. An explicit
579/// `changed_since` is the caller's own argument, so a ref that does not
580/// resolve fails the call later, in [`changed_files_for_run`]. An ambient
581/// `FALLOW_CHANGED_SINCE` comes from the environment the caller inherited, so
582/// a ref that does not resolve stands down here: the call runs at full scope
583/// and publishes `not-applied` with the CLI's reason token and sentence.
584fn resolve_changed_since(
585    options: &AnalysisOptions,
586    root: &Path,
587    request: &OnceLock<RequestOutcome>,
588    files: &OnceLock<FxHashSet<PathBuf>>,
589) -> Option<String> {
590    if let Some(git_ref) = options.changed_since.as_deref() {
591        return Some(git_ref.to_owned());
592    }
593    let git_ref = options.ambient_changed_since.as_deref()?;
594    match fallow_engine::changed_files::changed_files(root, git_ref) {
595        Ok(changed) => {
596            let _ = files.set(
597                changed
598                    .iter()
599                    .map(|path| dunce::simplified(path).to_path_buf())
600                    .collect(),
601            );
602            let _ = request.set(RequestOutcome::applied(RequestName::ChangedSince, git_ref));
603            Some(git_ref.to_owned())
604        }
605        Err(err) => {
606            let _ = request.set(RequestOutcome::not_applied(
607                RequestName::ChangedSince,
608                git_ref,
609                err.reason(),
610                err.changed_since_message(git_ref),
611            ));
612            None
613        }
614    }
615}
616
617pub fn changed_files_for_run(
618    resolved: &ProgrammaticAnalysisContext,
619) -> ProgrammaticResult<Option<FxHashSet<PathBuf>>> {
620    let Some(git_ref) = resolved.changed_since.as_deref() else {
621        return Ok(None);
622    };
623    fallow_engine::changed_files::changed_files(&resolved.root, git_ref)
624        .inspect(|files| resolved.record_changed_since_applied(git_ref, files))
625        .map(Some)
626        .map_err(|err| {
627            ProgrammaticError::new(
628                format!(
629                    "failed to resolve changed files for ref `{git_ref}`: {}",
630                    err.describe()
631                ),
632                2,
633            )
634            .with_code("FALLOW_CHANGED_FILES_FAILED")
635            .with_context("analysis.changedSince")
636        })
637}
638
639/// Check the workspace selection of the call against the session workspaces,
640/// for a route that reads the whole project but still rejects a selection
641/// that names no workspace.
642pub fn validate_workspace_selection(
643    resolved: &ProgrammaticAnalysisContext,
644    workspaces: &[WorkspaceInfo],
645) -> ProgrammaticResult<()> {
646    workspace_roots_for_session(resolved, workspaces).map(|_| ())
647}
648
649pub fn workspace_roots_for_session(
650    resolved: &ProgrammaticAnalysisContext,
651    workspaces: &[WorkspaceInfo],
652) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
653    resolve_workspace_scope_from_workspaces(
654        &resolved.root,
655        resolved.workspace.as_deref(),
656        resolved.changed_workspaces.as_deref(),
657        workspaces,
658    )
659}
660
661fn resolve_workspace_scope_from_workspaces(
662    root: &Path,
663    workspace: Option<&[String]>,
664    changed_workspaces: Option<&str>,
665    workspaces: &[WorkspaceInfo],
666) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
667    fallow_engine::workspace_scope::resolve_workspace_scope_roots(
668        root,
669        workspace,
670        changed_workspaces,
671        workspaces,
672    )
673    .map_err(map_workspace_scope_error)
674}
675
676fn map_workspace_scope_error(err: WorkspaceScopeError) -> ProgrammaticError {
677    match err {
678        WorkspaceScopeError::NoWorkspaces {
679            mode,
680            patterns,
681            git_ref,
682        } => map_no_workspaces_error(mode, &patterns, git_ref.as_deref()),
683        WorkspaceScopeError::InvalidPattern { pattern, message } => ProgrammaticError::new(
684            format!("invalid `workspace` pattern '{pattern}': {message}"),
685            2,
686        )
687        .with_code("FALLOW_INVALID_WORKSPACE_PATTERN")
688        .with_context("analysis.workspace"),
689        WorkspaceScopeError::UnmatchedPatterns {
690            patterns,
691            available,
692        } => ProgrammaticError::new(
693            format!(
694                "`workspace` matched no workspace for pattern{}: {}. Available: {available}",
695                if patterns.len() == 1 { "" } else { "s" },
696                quote_owned_patterns(&patterns),
697            ),
698            2,
699        )
700        .with_code("FALLOW_WORKSPACE_PATTERN_UNMATCHED")
701        .with_context("analysis.workspace"),
702        WorkspaceScopeError::EmptyAfterExclusions { .. } => {
703            ProgrammaticError::new("`workspace` excluded every discovered workspace", 2)
704                .with_code("FALLOW_WORKSPACE_SCOPE_EMPTY")
705                .with_context("analysis.workspace")
706        }
707        WorkspaceScopeError::ChangedWorkspacesFailed { git_ref, message } => {
708            ProgrammaticError::new(
709                format!("failed to resolve changed workspaces for ref `{git_ref}`: {message}"),
710                2,
711            )
712            .with_code("FALLOW_CHANGED_WORKSPACES_FAILED")
713            .with_context("analysis.changedWorkspaces")
714        }
715        WorkspaceScopeError::MutuallyExclusive => ProgrammaticError::new(
716            "`workspace` and `changed_workspaces` are mutually exclusive",
717            2,
718        )
719        .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
720        .with_context("analysis.workspace"),
721    }
722}
723
724fn map_no_workspaces_error(
725    mode: WorkspaceScopeMode,
726    patterns: &[String],
727    git_ref: Option<&str>,
728) -> ProgrammaticError {
729    match mode {
730        WorkspaceScopeMode::Workspace => ProgrammaticError::new(
731            format!(
732                "`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.",
733                quote_owned_patterns(patterns)
734            ),
735            2,
736        )
737        .with_code("FALLOW_WORKSPACES_NOT_FOUND")
738        .with_context("analysis.workspace"),
739        WorkspaceScopeMode::ChangedWorkspaces => {
740            let git_ref = git_ref.unwrap_or_default();
741            ProgrammaticError::new(
742                format!(
743                    "`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."
744                ),
745                2,
746            )
747            .with_code("FALLOW_WORKSPACES_NOT_FOUND")
748            .with_context("analysis.changedWorkspaces")
749        }
750    }
751}
752
753fn quote_owned_patterns(patterns: &[String]) -> String {
754    patterns
755        .iter()
756        .map(|pattern| format!("'{pattern}'"))
757        .collect::<Vec<_>>()
758        .join(", ")
759}
760
761#[cfg(test)]
762mod tests {
763    use std::process::Command;
764
765    use crate::AnalysisOptions;
766
767    const STACK_PROBE_ENV: &str = "FALLOW_API_STACK_PROBE_CHILD";
768    const STACK_PROBE_TEST: &str =
769        "analysis_context::tests::programmatic_pool_survives_deep_worker_stack_probe";
770
771    // A stack overflow aborts the whole process, so the probe re-runs this
772    // test binary as a child and asserts on its exit status; the same pattern
773    // guards the CLI global pool in crates/cli/src/rayon_pool.rs. The child
774    // drops RUST_MIN_STACK (pinned to 16 MiB in .cargo/config.toml, and
775    // inherited by default-sized rayon workers) so the probe still fails if
776    // the pool loses its explicit stack_size.
777    #[test]
778    fn programmatic_pool_survives_deep_worker_stack_probe() {
779        if std::env::var_os(STACK_PROBE_ENV).is_some() {
780            run_stack_probe_child();
781            return;
782        }
783
784        let current_exe = std::env::current_exe().expect("current test binary should be known");
785        let output = Command::new(current_exe)
786            .arg("--exact")
787            .arg(STACK_PROBE_TEST)
788            .arg("--nocapture")
789            .env(STACK_PROBE_ENV, "1")
790            .env_remove("RUST_MIN_STACK")
791            .output()
792            .expect("stack probe child should start");
793
794        assert!(
795            output.status.success(),
796            "stack probe child failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
797            output.status.code(),
798            String::from_utf8_lossy(&output.stdout),
799            String::from_utf8_lossy(&output.stderr)
800        );
801    }
802
803    fn run_stack_probe_child() {
804        let root = tempfile::tempdir().expect("stack probe needs a temp analysis root");
805        let options = AnalysisOptions {
806            root: Some(root.path().to_path_buf()),
807            threads: Some(1),
808            ..AnalysisOptions::default()
809        };
810        let context = super::resolve_programmatic_analysis_context(&options)
811            .expect("stack probe context should resolve");
812        assert_eq!(context.install(|| consume_stack(5_000)), 5_000);
813    }
814
815    #[inline(never)]
816    fn consume_stack(depth: usize) -> usize {
817        let frame = [0_u8; 2048];
818        std::hint::black_box(&frame);
819        if depth == 0 {
820            usize::from(frame[0])
821        } else {
822            1 + consume_stack(depth - 1)
823        }
824    }
825
826    #[test]
827    fn no_workspaces_error_names_the_config_key() {
828        for (mode, git_ref) in [
829            (super::WorkspaceScopeMode::Workspace, None),
830            (super::WorkspaceScopeMode::ChangedWorkspaces, Some("main")),
831        ] {
832            let err = super::map_no_workspaces_error(mode, &["web".to_owned()], git_ref);
833            assert!(
834                err.message
835                    .contains("\"workspaces.patterns\" in the fallow config"),
836                "{}",
837                err.message
838            );
839        }
840    }
841}