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 {
26 pub(crate) root: PathBuf,
27 pub(crate) config_path: Option<PathBuf>,
28 pub(crate) allow_remote_extends: bool,
29 pub(crate) no_cache: bool,
30 pub(crate) threads: usize,
31 pub(crate) pool: rayon::ThreadPool,
32 pub(crate) diff: Option<DiffIndex>,
33 pub(crate) diff_request: Option<RequestOutcome>,
35 pub(crate) production_override: Option<bool>,
36 pub(crate) changed_since: Option<String>,
39 pub(crate) changed_since_request: OnceLock<RequestOutcome>,
42 pub(crate) changed_since_files: OnceLock<FxHashSet<PathBuf>>,
44 pub(crate) change_scope_owner: ChangeScopeOwner,
47 pub(crate) no_package_baselines: bool,
49 pub(crate) package_baselines: PackageBaselineCache,
53 pub(crate) changed_since_analyzed: Mutex<Option<FxHashSet<PathBuf>>>,
56 pub(crate) workspace: Option<Vec<String>>,
57 pub(crate) changed_workspaces: Option<String>,
58 pub(crate) workspace_roots: Option<Vec<PathBuf>>,
59 pub(crate) explain: bool,
60 pub(crate) cancellation: Option<Arc<AtomicBool>>,
61}
62
63pub fn resolve_programmatic_analysis_context(
70 options: &AnalysisOptions,
71) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
72 resolve_programmatic_analysis_context_inner(options, true)
73}
74
75pub fn resolve_programmatic_analysis_context_deferred_workspace(
76 options: &AnalysisOptions,
77) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
78 resolve_programmatic_analysis_context_inner(options, false)
79}
80
81fn resolve_programmatic_analysis_context_inner(
82 options: &AnalysisOptions,
83 resolve_workspace: bool,
84) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
85 validate_analysis_option_shape(options)?;
86 let root = resolve_analysis_root(options.root.as_deref())?;
87 validate_analysis_config_path(options.config_path.as_deref())?;
88 let threads = options.threads.unwrap_or_else(default_threads);
89 let pool = fallow_engine::thread_pool::worker_pool_builder(threads)
90 .build()
91 .map_err(|err| {
92 ProgrammaticError::new(format!("failed to build analysis thread pool: {err}"), 2)
93 .with_code("FALLOW_THREAD_POOL_INIT_FAILED")
94 .with_context("analysis.threads")
95 })?;
96 let (diff, diff_request) = resolve_diff(options, &root)?;
97 let changed_since_request = OnceLock::new();
98 let changed_since_files = OnceLock::new();
99 let changed_since =
100 resolve_changed_since(options, &root, &changed_since_request, &changed_since_files);
101 let workspace_roots = if resolve_workspace {
102 resolve_workspace_scope(
103 &root,
104 options.workspace.as_deref(),
105 options.changed_workspaces.as_deref(),
106 )?
107 } else {
108 None
109 };
110 Ok(ProgrammaticAnalysisContext {
111 root,
112 config_path: options.config_path.clone(),
113 allow_remote_extends: options.allow_remote_extends,
114 no_cache: options.no_cache,
115 threads,
116 pool,
117 diff,
118 diff_request,
119 production_override: options
120 .production_override
121 .or_else(|| options.production.then_some(true)),
122 changed_since,
123 changed_since_request,
124 changed_since_files,
125 change_scope_owner: ChangeScopeOwner::Run,
126 no_package_baselines: options.no_package_baselines,
127 package_baselines: PackageBaselineCache::new(),
128 changed_since_analyzed: Mutex::new(None),
129 workspace: options.workspace.clone(),
130 changed_workspaces: options.changed_workspaces.clone(),
131 workspace_roots,
132 explain: options.explain,
133 cancellation: options.cancellation.clone(),
134 })
135}
136
137fn validate_analysis_option_shape(options: &AnalysisOptions) -> ProgrammaticResult<()> {
138 if options.threads == Some(0) {
139 return Err(
140 ProgrammaticError::new("`threads` must be greater than 0", 2)
141 .with_code("FALLOW_INVALID_THREADS")
142 .with_context("analysis.threads"),
143 );
144 }
145 if options.workspace.is_some() && options.changed_workspaces.is_some() {
146 return Err(ProgrammaticError::new(
147 "`workspace` and `changed_workspaces` are mutually exclusive",
148 2,
149 )
150 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
151 .with_context("analysis.workspace"));
152 }
153 Ok(())
154}
155
156pub fn resolve_analysis_root(root: Option<&Path>) -> ProgrammaticResult<PathBuf> {
157 let root = match root {
158 Some(root) => root.to_path_buf(),
159 None => std::env::current_dir().map_err(|err| {
160 ProgrammaticError::new(
161 format!("failed to resolve current working directory: {err}"),
162 2,
163 )
164 .with_code("FALLOW_CWD_UNAVAILABLE")
165 .with_context("analysis.root")
166 })?,
167 };
168 fallow_engine::validate::validate_root(&root).map_err(|err| {
169 ProgrammaticError::new(err, 2)
170 .with_code("FALLOW_INVALID_ROOT")
171 .with_context("analysis.root")
172 })
173}
174
175pub fn validate_analysis_config_path(config_path: Option<&Path>) -> ProgrammaticResult<()> {
176 if let Some(config_path) = config_path
177 && !config_path.exists()
178 {
179 return Err(ProgrammaticError::new(
180 format!("config file does not exist: {}", config_path.display()),
181 2,
182 )
183 .with_code("FALLOW_INVALID_CONFIG_PATH")
184 .with_context("analysis.configPath"));
185 }
186 Ok(())
187}
188
189impl ProgrammaticAnalysisContext {
190 pub fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
192 self.pool.install(f)
193 }
194
195 #[must_use]
197 pub fn root(&self) -> &Path {
198 &self.root
199 }
200
201 #[must_use]
203 pub fn config_path(&self) -> &Option<PathBuf> {
204 &self.config_path
205 }
206
207 #[must_use]
209 pub const fn allow_remote_extends(&self) -> bool {
210 self.allow_remote_extends
211 }
212
213 #[must_use]
215 pub const fn no_cache(&self) -> bool {
216 self.no_cache
217 }
218
219 #[must_use]
221 pub const fn threads(&self) -> usize {
222 self.threads
223 }
224
225 #[must_use]
227 pub const fn diff_index(&self) -> Option<&DiffIndex> {
228 self.diff.as_ref()
229 }
230
231 #[must_use]
237 pub fn request_outcomes(&self) -> Option<RequestOutcomes> {
238 let mut requests = RequestOutcomes::new();
239 requests.insert_if(RequestName::ChangedSince, self.changed_since_outcome());
240 requests.insert_if(RequestName::DiffFilter, self.diff_request.clone());
241 requests.insert_if(
242 RequestName::PackageBaselines,
243 self.package_baselines.request_outcome(),
244 );
245 requests.into_option()
246 }
247
248 fn changed_since_outcome(&self) -> Option<RequestOutcome> {
251 let outcome = self.changed_since_request.get()?.clone();
252 let size = self
253 .changed_since_analyzed
254 .lock()
255 .ok()
256 .and_then(|analyzed| analyzed.as_ref().map(|files| files.len() as u64));
257 Some(match size {
258 Some(size) if outcome.status == fallow_output::RequestStatus::Applied => {
259 RequestOutcome {
260 scope_size: Some(size),
261 ..outcome
262 }
263 }
264 _ => outcome,
265 })
266 }
267
268 pub(crate) fn measure_changed_since_scope<'a>(
271 &self,
272 analyzed: impl IntoIterator<Item = &'a Path>,
273 ) {
274 let Some(changed) = self.changed_since_files.get() else {
275 return;
276 };
277 let Ok(mut union) = self.changed_since_analyzed.lock() else {
278 return;
279 };
280 union.get_or_insert_with(FxHashSet::default).extend(
281 analyzed
282 .into_iter()
283 .map(dunce::simplified)
284 .filter(|path| changed.contains(*path))
285 .map(Path::to_path_buf),
286 );
287 }
288
289 fn record_changed_since_applied(&self, git_ref: &str, files: &FxHashSet<PathBuf>) {
292 let _ = self.changed_since_files.set(
293 files
294 .iter()
295 .map(|path| dunce::simplified(path).to_path_buf())
296 .collect(),
297 );
298 let _ = self
299 .changed_since_request
300 .set(RequestOutcome::applied(RequestName::ChangedSince, git_ref));
301 }
302
303 pub(crate) fn record_changed_since_from_runner(&self, kept: Option<&[PathBuf]>) {
306 let (Some(git_ref), Some(kept)) = (self.changed_since.as_deref(), kept) else {
307 return;
308 };
309 if self.changed_since_files.get().is_none() {
310 let files: FxHashSet<PathBuf> = kept.iter().cloned().collect();
311 self.record_changed_since_applied(git_ref, &files);
312 }
313 self.measure_changed_since_scope(kept.iter().map(PathBuf::as_path));
314 }
315
316 #[must_use]
318 pub const fn production_override(&self) -> Option<bool> {
319 self.production_override
320 }
321
322 #[must_use]
324 pub fn changed_since(&self) -> Option<&str> {
325 self.changed_since.as_deref()
326 }
327
328 #[must_use]
330 pub(crate) const fn with_change_scope_owner(mut self, owner: ChangeScopeOwner) -> Self {
331 self.change_scope_owner = owner;
332 self
333 }
334
335 pub(crate) fn change_scope(
341 &self,
342 files: Option<&FxHashSet<PathBuf>>,
343 config: &ResolvedConfig,
344 workspaces: &[WorkspaceInfo],
345 ) -> ProgrammaticResult<ChangeScope> {
346 let request = ChangeScopeRequest {
347 owner: self.change_scope_owner,
348 global_ref: self.changed_since.is_some() || self.changed_since_request.get().is_some(),
349 files,
350 cache: Some(&self.package_baselines),
351 no_package_baselines: self.no_package_baselines,
352 };
353 ChangeScope::resolve(request, config, workspaces).map_err(|err| {
354 ProgrammaticError::new(format!("workspace baseline error: {err}"), 2)
355 .with_code("FALLOW_PACKAGE_BASELINE_FAILED")
356 .with_context("analysis.workspaces.changedSince")
357 })
358 }
359
360 #[must_use]
362 pub fn workspace(&self) -> Option<&[String]> {
363 self.workspace.as_deref()
364 }
365
366 #[must_use]
368 pub fn changed_workspaces(&self) -> Option<&str> {
369 self.changed_workspaces.as_deref()
370 }
371
372 #[must_use]
374 pub const fn explain_enabled(&self) -> bool {
375 self.explain
376 }
377
378 #[must_use]
380 pub fn cancellation(&self) -> Option<&Arc<AtomicBool>> {
381 self.cancellation.as_ref()
382 }
383
384 #[must_use]
386 pub fn is_cancelled(&self) -> bool {
387 self.cancellation
388 .as_ref()
389 .is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
390 }
391
392 pub fn ensure_not_cancelled(&self, stage: &str) -> ProgrammaticResult<()> {
403 if self.is_cancelled() {
404 return Err(cancelled_error(stage));
405 }
406 Ok(())
407 }
408}
409
410pub fn ensure_options_not_cancelled(
419 options: &AnalysisOptions,
420 stage: &str,
421) -> ProgrammaticResult<()> {
422 if options
423 .cancellation
424 .as_ref()
425 .is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
426 {
427 return Err(cancelled_error(stage));
428 }
429 Ok(())
430}
431
432#[must_use]
437pub fn cancelled_error(stage: &str) -> ProgrammaticError {
438 cancelled_error_message(&format!("analysis was cancelled before {stage}"))
439}
440
441#[must_use]
443pub fn cancelled_error_message(message: &str) -> ProgrammaticError {
444 ProgrammaticError::new(message, 2)
445 .with_code("FALLOW_CANCELLED")
446 .with_context("analysis.cancellation")
447}
448
449fn default_threads() -> usize {
450 std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
451}
452
453fn resolve_diff(
461 options: &AnalysisOptions,
462 root: &Path,
463) -> ProgrammaticResult<(Option<DiffIndex>, Option<RequestOutcome>)> {
464 if let Some(path) = options.diff_file.as_deref() {
465 let index = load_explicit_diff_file(path, root)?;
466 let request = diff_applied(format!("diffFile {}", path.display()), &index);
467 return Ok((Some(index), Some(request)));
468 }
469 let Some(path) = options.ambient_diff_file.as_deref() else {
470 return Ok((None, None));
471 };
472 Ok(load_ambient_diff_file(path, root))
473}
474
475fn load_ambient_diff_file(path: &Path, root: &Path) -> (Option<DiffIndex>, Option<RequestOutcome>) {
478 let abs = if path.is_absolute() {
479 path.to_path_buf()
480 } else {
481 root.join(path)
482 };
483 let label = format!("$FALLOW_DIFF_FILE {}", abs.display());
484 let placed = fallow_engine::diff_source::read_diff_file(&abs, &label).and_then(|text| {
485 fallow_engine::diff_source::place_diff(
486 DiffIndex::from_unified_diff(&text),
487 root,
488 &fallow_engine::diff_source::diff_base_candidates(root),
489 &label,
490 )
491 });
492 match placed {
493 Ok(index) => {
494 let request = diff_applied(label, &index);
495 (Some(index), Some(request))
496 }
497 Err(stand_down) => {
498 let (reason, message) = stand_down.into_parts();
499 let request =
500 RequestOutcome::not_applied(RequestName::DiffFilter, label, reason, message);
501 (None, Some(request))
502 }
503 }
504}
505
506fn diff_applied(label: String, index: &DiffIndex) -> RequestOutcome {
508 RequestOutcome::applied_with_scope_size(
509 RequestName::DiffFilter,
510 label,
511 index.added_line_count() as u64,
512 )
513}
514
515fn load_explicit_diff_file(path: &Path, root: &Path) -> ProgrammaticResult<DiffIndex> {
516 if path == Path::new("-") {
517 return Err(ProgrammaticError::new(
518 "`diff_file` does not support stdin; pass a file path",
519 2,
520 )
521 .with_code("FALLOW_INVALID_DIFF_FILE")
522 .with_context("analysis.diffFile"));
523 }
524 let abs = if is_absolute_path_any_platform(path) {
525 path.to_path_buf()
526 } else {
527 root.join(path)
528 };
529 let meta = std::fs::metadata(&abs).map_err(|err| {
530 ProgrammaticError::new(
531 format!(
532 "diff file does not exist or cannot be read: {} ({err})",
533 abs.display()
534 ),
535 2,
536 )
537 .with_code("FALLOW_INVALID_DIFF_FILE")
538 .with_context("analysis.diffFile")
539 })?;
540 if !meta.is_file() {
541 return Err(ProgrammaticError::new(
542 format!("diff path is not a file: {}", abs.display()),
543 2,
544 )
545 .with_code("FALLOW_INVALID_DIFF_FILE")
546 .with_context("analysis.diffFile"));
547 }
548 if meta.len() > MAX_DIFF_BYTES {
549 return Err(ProgrammaticError::new(
550 format!(
551 "diff file is {} bytes, above the {MAX_DIFF_BYTES} byte limit: {}",
552 meta.len(),
553 abs.display()
554 ),
555 2,
556 )
557 .with_code("FALLOW_INVALID_DIFF_FILE")
558 .with_context("analysis.diffFile"));
559 }
560 let text = std::fs::read_to_string(&abs).map_err(|err| {
561 ProgrammaticError::new(
562 format!("failed to read diff file {}: {err}", abs.display()),
563 2,
564 )
565 .with_code("FALLOW_INVALID_DIFF_FILE")
566 .with_context("analysis.diffFile")
567 })?;
568 Ok(DiffIndex::from_unified_diff(&text))
569}
570
571fn resolve_changed_since(
581 options: &AnalysisOptions,
582 root: &Path,
583 request: &OnceLock<RequestOutcome>,
584 files: &OnceLock<FxHashSet<PathBuf>>,
585) -> Option<String> {
586 if let Some(git_ref) = options.changed_since.as_deref() {
587 return Some(git_ref.to_owned());
588 }
589 let git_ref = options.ambient_changed_since.as_deref()?;
590 match fallow_engine::changed_files::changed_files(root, git_ref) {
591 Ok(changed) => {
592 let _ = files.set(
593 changed
594 .iter()
595 .map(|path| dunce::simplified(path).to_path_buf())
596 .collect(),
597 );
598 let _ = request.set(RequestOutcome::applied(RequestName::ChangedSince, git_ref));
599 Some(git_ref.to_owned())
600 }
601 Err(err) => {
602 let _ = request.set(RequestOutcome::not_applied(
603 RequestName::ChangedSince,
604 git_ref,
605 err.reason(),
606 err.changed_since_message(git_ref),
607 ));
608 None
609 }
610 }
611}
612
613pub fn changed_files_for_run(
614 resolved: &ProgrammaticAnalysisContext,
615) -> ProgrammaticResult<Option<FxHashSet<PathBuf>>> {
616 let Some(git_ref) = resolved.changed_since.as_deref() else {
617 return Ok(None);
618 };
619 fallow_engine::changed_files::changed_files(&resolved.root, git_ref)
620 .inspect(|files| resolved.record_changed_since_applied(git_ref, files))
621 .map(Some)
622 .map_err(|err| {
623 ProgrammaticError::new(
624 format!(
625 "failed to resolve changed files for ref `{git_ref}`: {}",
626 err.describe()
627 ),
628 2,
629 )
630 .with_code("FALLOW_CHANGED_FILES_FAILED")
631 .with_context("analysis.changedSince")
632 })
633}
634
635pub fn workspace_roots_for_session(
636 resolved: &ProgrammaticAnalysisContext,
637 workspaces: &[WorkspaceInfo],
638) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
639 resolve_workspace_scope_from_workspaces(
640 &resolved.root,
641 resolved.workspace.as_deref(),
642 resolved.changed_workspaces.as_deref(),
643 workspaces,
644 )
645}
646
647fn resolve_workspace_scope(
648 root: &Path,
649 workspace: Option<&[String]>,
650 changed_workspaces: Option<&str>,
651) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
652 fallow_engine::workspace_scope::resolve_workspace_scope_roots_for_project(
653 root,
654 workspace,
655 changed_workspaces,
656 )
657 .map_err(map_workspace_scope_error)
658}
659
660fn resolve_workspace_scope_from_workspaces(
661 root: &Path,
662 workspace: Option<&[String]>,
663 changed_workspaces: Option<&str>,
664 workspaces: &[WorkspaceInfo],
665) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
666 fallow_engine::workspace_scope::resolve_workspace_scope_roots(
667 root,
668 workspace,
669 changed_workspaces,
670 workspaces,
671 )
672 .map_err(map_workspace_scope_error)
673}
674
675fn map_workspace_scope_error(err: WorkspaceScopeError) -> ProgrammaticError {
676 match err {
677 WorkspaceScopeError::NoWorkspaces {
678 mode,
679 patterns,
680 git_ref,
681 } => map_no_workspaces_error(mode, &patterns, git_ref.as_deref()),
682 WorkspaceScopeError::InvalidPattern { pattern, message } => ProgrammaticError::new(
683 format!("invalid `workspace` pattern '{pattern}': {message}"),
684 2,
685 )
686 .with_code("FALLOW_INVALID_WORKSPACE_PATTERN")
687 .with_context("analysis.workspace"),
688 WorkspaceScopeError::UnmatchedPatterns {
689 patterns,
690 available,
691 } => ProgrammaticError::new(
692 format!(
693 "`workspace` matched no workspace for pattern{}: {}. Available: {available}",
694 if patterns.len() == 1 { "" } else { "s" },
695 quote_owned_patterns(&patterns),
696 ),
697 2,
698 )
699 .with_code("FALLOW_WORKSPACE_PATTERN_UNMATCHED")
700 .with_context("analysis.workspace"),
701 WorkspaceScopeError::EmptyAfterExclusions { .. } => {
702 ProgrammaticError::new("`workspace` excluded every discovered workspace", 2)
703 .with_code("FALLOW_WORKSPACE_SCOPE_EMPTY")
704 .with_context("analysis.workspace")
705 }
706 WorkspaceScopeError::ChangedWorkspacesFailed { git_ref, message } => {
707 ProgrammaticError::new(
708 format!("failed to resolve changed workspaces for ref `{git_ref}`: {message}"),
709 2,
710 )
711 .with_code("FALLOW_CHANGED_WORKSPACES_FAILED")
712 .with_context("analysis.changedWorkspaces")
713 }
714 WorkspaceScopeError::MutuallyExclusive => ProgrammaticError::new(
715 "`workspace` and `changed_workspaces` are mutually exclusive",
716 2,
717 )
718 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
719 .with_context("analysis.workspace"),
720 }
721}
722
723fn map_no_workspaces_error(
724 mode: WorkspaceScopeMode,
725 patterns: &[String],
726 git_ref: Option<&str>,
727) -> ProgrammaticError {
728 match mode {
729 WorkspaceScopeMode::Workspace => ProgrammaticError::new(
730 format!(
731 "`workspace` {} specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\".",
732 quote_owned_patterns(patterns)
733 ),
734 2,
735 )
736 .with_code("FALLOW_WORKSPACES_NOT_FOUND")
737 .with_context("analysis.workspace"),
738 WorkspaceScopeMode::ChangedWorkspaces => {
739 let git_ref = git_ref.unwrap_or_default();
740 ProgrammaticError::new(
741 format!(
742 "`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\"."
743 ),
744 2,
745 )
746 .with_code("FALLOW_WORKSPACES_NOT_FOUND")
747 .with_context("analysis.changedWorkspaces")
748 }
749 }
750}
751
752fn quote_owned_patterns(patterns: &[String]) -> String {
753 patterns
754 .iter()
755 .map(|pattern| format!("'{pattern}'"))
756 .collect::<Vec<_>>()
757 .join(", ")
758}
759
760#[cfg(test)]
761mod tests {
762 use std::process::Command;
763
764 use crate::AnalysisOptions;
765
766 const STACK_PROBE_ENV: &str = "FALLOW_API_STACK_PROBE_CHILD";
767 const STACK_PROBE_TEST: &str =
768 "analysis_context::tests::programmatic_pool_survives_deep_worker_stack_probe";
769
770 #[test]
777 fn programmatic_pool_survives_deep_worker_stack_probe() {
778 if std::env::var_os(STACK_PROBE_ENV).is_some() {
779 run_stack_probe_child();
780 return;
781 }
782
783 let current_exe = std::env::current_exe().expect("current test binary should be known");
784 let output = Command::new(current_exe)
785 .arg("--exact")
786 .arg(STACK_PROBE_TEST)
787 .arg("--nocapture")
788 .env(STACK_PROBE_ENV, "1")
789 .env_remove("RUST_MIN_STACK")
790 .output()
791 .expect("stack probe child should start");
792
793 assert!(
794 output.status.success(),
795 "stack probe child failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
796 output.status.code(),
797 String::from_utf8_lossy(&output.stdout),
798 String::from_utf8_lossy(&output.stderr)
799 );
800 }
801
802 fn run_stack_probe_child() {
803 let root = tempfile::tempdir().expect("stack probe needs a temp analysis root");
804 let options = AnalysisOptions {
805 root: Some(root.path().to_path_buf()),
806 threads: Some(1),
807 ..AnalysisOptions::default()
808 };
809 let context = super::resolve_programmatic_analysis_context(&options)
810 .expect("stack probe context should resolve");
811 assert_eq!(context.install(|| consume_stack(5_000)), 5_000);
812 }
813
814 #[inline(never)]
815 fn consume_stack(depth: usize) -> usize {
816 let frame = [0_u8; 2048];
817 std::hint::black_box(&frame);
818 if depth == 0 {
819 usize::from(frame[0])
820 } else {
821 1 + consume_stack(depth - 1)
822 }
823 }
824}