1use 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
20pub 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 pub(crate) diff_request: Option<RequestOutcome>,
40 pub(crate) production_override: Option<bool>,
41 pub(crate) changed_since: Option<String>,
44 pub(crate) changed_since_request: OnceLock<RequestOutcome>,
47 pub(crate) changed_since_files: OnceLock<FxHashSet<PathBuf>>,
49 pub(crate) change_scope_owner: ChangeScopeOwner,
52 pub(crate) no_package_baselines: bool,
54 pub(crate) package_baselines: PackageBaselineCache,
58 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
67pub 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 pub fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
173 self.pool.install(f)
174 }
175
176 #[must_use]
178 pub fn root(&self) -> &Path {
179 &self.root
180 }
181
182 #[must_use]
184 pub fn config_path(&self) -> &Option<PathBuf> {
185 &self.config_path
186 }
187
188 #[must_use]
190 pub const fn allow_remote_extends(&self) -> bool {
191 self.allow_remote_extends
192 }
193
194 #[must_use]
196 pub const fn no_cache(&self) -> bool {
197 self.no_cache
198 }
199
200 #[must_use]
202 pub const fn threads(&self) -> usize {
203 self.threads
204 }
205
206 #[must_use]
208 pub const fn diff_index(&self) -> Option<&DiffIndex> {
209 self.diff.as_ref()
210 }
211
212 #[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 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 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 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 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 #[must_use]
299 pub const fn production_override(&self) -> Option<bool> {
300 self.production_override
301 }
302
303 #[must_use]
305 pub fn changed_since(&self) -> Option<&str> {
306 self.changed_since.as_deref()
307 }
308
309 #[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 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 #[must_use]
343 pub fn workspace(&self) -> Option<&[String]> {
344 self.workspace.as_deref()
345 }
346
347 #[must_use]
349 pub fn changed_workspaces(&self) -> Option<&str> {
350 self.changed_workspaces.as_deref()
351 }
352
353 #[must_use]
355 pub const fn explain_enabled(&self) -> bool {
356 self.explain
357 }
358
359 #[must_use]
361 pub fn cancellation(&self) -> Option<&Arc<AtomicBool>> {
362 self.cancellation.as_ref()
363 }
364
365 #[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 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
391pub 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#[must_use]
418pub fn cancelled_error(stage: &str) -> ProgrammaticError {
419 cancelled_error_message(&format!("analysis was cancelled before {stage}"))
420}
421
422#[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
434fn 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
479fn 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
510fn 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
575fn 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
639pub 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 #[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}