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