alba_core/graph.rs
1//! Dependency-graph validation and subgraph extraction, the last piece a
2//! [`Project`] needs before an engine can schedule it: [`validate_graph`]
3//! checks that every `needs` entry — and the file's `default`, if it
4//! declares one — names a beam that actually exists, and that no beam
5//! (transitively) needs itself; [`execution_subgraph`] extracts the
6//! transitive closure of a target beam.
7//!
8//! [`validate_graph`] is called by [`crate::loader::load_project`] and
9//! [`crate::eval::load_str`] before either hands a [`Project`] back to its
10//! caller, so a `Project` that exists at all has already passed both
11//! checks — [`execution_subgraph`] leans on that invariant (see its own
12//! doc comment) rather than re-deriving it.
13//!
14//! ## Determinism
15//!
16//! Both functions build a `HashMap<&str, &Beam>` for O(1) id lookups, but
17//! never iterate it: every loop that could affect *which* error gets
18//! reported, or the DFS visiting order, walks [`Project::beams`] (a `Vec`,
19//! in the order beams were declared) instead. A `HashMap`'s iteration
20//! order is randomized per instance, so leaning on it here would make
21//! which of two independent problems gets reported change from run to
22//! run — exactly the class of defect this crate has already hit once.
23
24use std::collections::{HashMap, HashSet, VecDeque};
25
26use alba_syntax::Span;
27
28use crate::error::CoreError;
29use crate::eval::suggest;
30use crate::model::{Beam, BeamId, Project};
31
32/// Every beam id declared in `project`, in declaration order — the
33/// candidate list offered to [`suggest`] for an "unknown beam" error's
34/// "did you mean...?" help.
35fn all_ids(project: &Project) -> impl Iterator<Item = &str> {
36 project.beams.iter().map(|b| b.id.0.as_str())
37}
38
39/// An O(1) id-to-beam lookup for `project`, built fresh on every call.
40/// Shared by [`validate_graph`] and [`execution_subgraph`], which both
41/// need one, so the same map isn't built twice per call in a caller that
42/// validates and then immediately extracts a subgraph. Only ever used for
43/// lookups, never iterated — see the module doc comment's "Determinism"
44/// section for why that distinction matters here.
45fn by_id(project: &Project) -> HashMap<&str, &Beam> {
46 project.beams.iter().map(|b| (b.id.0.as_str(), b)).collect()
47}
48
49/// Builds the "unknown beam `name`" error `validate_graph` and
50/// `execution_subgraph` both need, with a "did you mean...?" suggestion
51/// attached when a close-enough candidate exists. `span` is `None` when
52/// the name came from outside the Beamfile and so has no declaration site
53/// to underline. The caller stamps the right `source_id` afterward (see
54/// [`CoreError::with_source_id`]) since this helper has no beam of its own
55/// to read one from.
56fn unknown_beam_error<'a>(
57 name: &str,
58 candidates: impl Iterator<Item = &'a str>,
59 span: Option<Span>,
60) -> CoreError {
61 let message = format!("unknown beam `{name}`");
62 let err = match span {
63 Some(span) => CoreError::new(message, span),
64 None => CoreError::unlocated(message),
65 };
66 match suggest(name, candidates) {
67 Some(candidate) => err.with_help(format!("did you mean `{candidate}`?")),
68 None => err,
69 }
70}
71
72/// Checks that every `needs` entry in `project` names a beam that actually
73/// exists, that `project`'s `default` (if it declares one) does too, and
74/// that no beam (transitively) needs itself.
75///
76/// The checks run as separate passes over [`Project::beams`], in
77/// declaration order: first every `needs` entry against the full set of
78/// declared ids, then the `default`, then cycle detection (which assumes
79/// every `needs` entry already resolves, since the first pass would have
80/// already returned an error otherwise). Each reports at the exact
81/// reference that does not resolve, and against whichever beam declared
82/// it — its own `source`, via [`CoreError::with_source_id`] — never the
83/// caller's; this function runs after every file has finished loading,
84/// when no `SourceIdScope` is active, so leaving `source_id` at its
85/// default would always point a multi-file project's error at the root
86/// file regardless of which imported file actually declared the problem.
87/// The `default` needs no such correction: only the root file's `default`
88/// is ever kept (see [`crate::loader::load_project`]), so it always
89/// belongs to the file `SourceId`'s own default already names.
90pub fn validate_graph(project: &Project) -> Result<(), CoreError> {
91 let by_id = by_id(project);
92
93 for beam in &project.beams {
94 for need in &beam.needs {
95 if !by_id.contains_key(need.value.0.as_str()) {
96 return Err(
97 unknown_beam_error(&need.value.0, all_ids(project), Some(need.span))
98 .with_source_id(beam.source),
99 );
100 }
101 }
102 }
103
104 if let Some(default) = &project.default
105 && !by_id.contains_key(default.value.0.as_str())
106 {
107 return Err(unknown_beam_error(
108 &default.value.0,
109 all_ids(project),
110 Some(default.span),
111 ));
112 }
113
114 for hook in &project.hooks {
115 let Some(&target) = by_id.get(hook.beam.value.0.as_str()) else {
116 return Err(unknown_beam_error(
117 &hook.beam.value.0,
118 all_ids(project),
119 Some(hook.beam.span),
120 )
121 .with_source_id(hook.source));
122 };
123 if target.params.len() > hook.arity {
124 let arguments = if hook.arity == 1 {
125 "argument"
126 } else {
127 "arguments"
128 };
129 let parameters = if target.params.len() == 1 {
130 "parameter"
131 } else {
132 "parameters"
133 };
134 return Err(CoreError::new(
135 format!(
136 "hook `{}` passes {} {arguments}, but beam `{}` declares {} {parameters}",
137 hook.name,
138 hook.arity,
139 target.id.0,
140 target.params.len()
141 ),
142 hook.beam.span,
143 )
144 .with_source_id(hook.source));
145 }
146 }
147
148 let mut done: HashSet<&str> = HashSet::new();
149 for beam in &project.beams {
150 if done.contains(beam.id.0.as_str()) {
151 continue;
152 }
153 detect_cycle(beam, &by_id, &mut done)?;
154 }
155
156 Ok(())
157}
158
159/// One entry of [`detect_cycle`]'s explicit search stack: either a beam to
160/// descend into, or the marker that pops a beam back off the current path
161/// once its whole subtree has been explored (what returning from a
162/// recursive call would have done).
163enum Step<'a> {
164 Enter(&'a Beam),
165 Leave(&'a str),
166}
167
168/// Depth-first search from `root`, following `needs` edges, tracking the
169/// current branch in `path` (ids "on the stack" for this walk, not the
170/// whole project). `done` records beams whose subtree has already been
171/// fully explored (by this call or an earlier one from
172/// [`validate_graph`]'s outer loop), so a beam reachable from more than
173/// one root is never walked twice.
174///
175/// The search keeps its own heap-allocated stack rather than recursing per
176/// `needs` edge. Depth here is a property of the *Beamfile*, not of Alba: a
177/// chain of beams each needing the next, declared in that order, descends
178/// as deep as the chain is long. Recursion made that a stack overflow —
179/// which aborts the process outright rather than panicking, so a library
180/// would take its caller down with no diagnostic at all.
181///
182/// A cycle is detected the instant a beam's own id is already present in
183/// `path`: the reported cycle is `path[pos..]` (the loop itself, starting
184/// from where it closes) with that id appended once more to show it
185/// closing — not the full path from whatever root started this walk. For
186/// `build → codegen → build`, `path` at the point of detection is
187/// `["build", "codegen"]` and the beam being entered is `build` again, so
188/// `pos` is `0` and the reported cycle is exactly
189/// `["build", "codegen", "build"]`. A self-referencing beam (`a` needing
190/// `a`) falls out of the same logic: `path` is `["a"]`, the beam is `a`,
191/// `pos` is `0`, reported as `"a → a"`.
192///
193/// Visiting order matches what recursion produced, so which of several
194/// independent cycles gets reported does not change: a beam's `needs` are
195/// pushed in reverse so the first entry is the first one entered.
196///
197/// The error is stamped with the closing beam's own `span`/`source` (see
198/// [`validate_graph`]'s doc comment).
199fn detect_cycle<'a>(
200 root: &'a Beam,
201 by_id: &HashMap<&'a str, &'a Beam>,
202 done: &mut HashSet<&'a str>,
203) -> Result<(), CoreError> {
204 let mut path: Vec<&'a str> = Vec::new();
205 let mut stack: Vec<Step<'a>> = vec![Step::Enter(root)];
206
207 while let Some(step) = stack.pop() {
208 let beam = match step {
209 Step::Leave(id) => {
210 path.pop();
211 done.insert(id);
212 continue;
213 }
214 Step::Enter(beam) => beam,
215 };
216 let id = beam.id.0.as_str();
217
218 if let Some(pos) = path.iter().position(|&on_path| on_path == id) {
219 let mut cycle: Vec<&str> = path[pos..].to_vec();
220 cycle.push(id);
221 return Err(CoreError::new(
222 format!("dependency cycle: {}", cycle.join(" → ")),
223 beam.span,
224 )
225 .with_source_id(beam.source));
226 }
227
228 if done.contains(id) {
229 continue;
230 }
231
232 path.push(id);
233 stack.push(Step::Leave(id));
234 for need in beam.needs.iter().rev() {
235 // Safe to index directly: `validate_graph`'s first pass already
236 // rejected any `needs` entry that doesn't resolve, and
237 // `execution_subgraph` never calls this function.
238 stack.push(Step::Enter(by_id[need.value.0.as_str()]));
239 }
240 }
241
242 Ok(())
243}
244
245/// The transitive closure of `target` (itself included), unordered:
246/// scheduling order is the engine's job, not this crate's. A `needs`
247/// entry repeated within one beam (`needs [a, a]`) contributes `a` to the
248/// closure once, not twice.
249///
250/// An unknown `target` is an error, with the same "did you mean...?"
251/// treatment [`validate_graph`] gives an unknown `needs` entry — but with
252/// no span at all, since `target` is a plain caller-supplied [`BeamId`]
253/// (typically typed on a command line) with no declaration site of its own
254/// to point at. Underlining an arbitrary beam declaration instead would
255/// assert a source position that is not where the mistake is.
256///
257/// Every `needs` entry this walks is assumed to resolve: any [`Project`]
258/// a caller can actually hold came from [`crate::loader::load_project`] or
259/// [`crate::eval::load_str`], both of which run [`validate_graph`] first —
260/// so an unresolved `needs` entry here would mean that invariant was
261/// somehow bypassed. Rather than panicking on that (a library should not
262/// crash its caller over an invariant it cannot fully enforce, since
263/// `Project`'s fields are public), a `needs` entry that doesn't resolve is
264/// silently skipped instead of extended into.
265pub fn execution_subgraph(project: &Project, target: &BeamId) -> Result<Vec<BeamId>, CoreError> {
266 let by_id = by_id(project);
267
268 let Some(&start) = by_id.get(target.0.as_str()) else {
269 return Err(unknown_beam_error(&target.0, all_ids(project), None));
270 };
271
272 let mut seen: HashSet<&str> = HashSet::new();
273 let mut queue: VecDeque<&str> = VecDeque::new();
274 seen.insert(start.id.0.as_str());
275 queue.push_back(start.id.0.as_str());
276
277 let mut closure = Vec::new();
278 while let Some(id) = queue.pop_front() {
279 closure.push(BeamId(id.to_string()));
280 if let Some(&beam) = by_id.get(id) {
281 for need in &beam.needs {
282 if seen.insert(need.value.0.as_str()) {
283 queue.push_back(need.value.0.as_str());
284 }
285 }
286 }
287 }
288
289 Ok(closure)
290}
291
292#[cfg(test)]
293mod tests {
294 use std::path::PathBuf;
295
296 use super::*;
297 use crate::eval::load_str;
298 use crate::loader::load_project;
299 use crate::model::SourceId;
300
301 /// Sorts a closure's ids into plain, comparable `String`s.
302 /// `execution_subgraph`'s result is unordered by contract (scheduling
303 /// order is the engine's job), so every test compares a sorted view
304 /// rather than depending on traversal order.
305 fn sorted(ids: Vec<BeamId>) -> Vec<String> {
306 let mut v: Vec<String> = ids.into_iter().map(|id| id.0).collect();
307 v.sort();
308 v
309 }
310
311 /// Writes `content` to `path`, creating any missing parent directories
312 /// first. Mirrors `tests/loader.rs`'s helper of the same name, for the
313 /// one test here that needs a real multi-file project on disk (to
314 /// check the `source_id` an imported file's own error carries).
315 fn write(path: PathBuf, content: &str) {
316 if let Some(parent) = path.parent() {
317 std::fs::create_dir_all(parent).unwrap();
318 }
319 std::fs::write(path, content).unwrap();
320 }
321
322 #[test]
323 fn unknown_need_suggests_closest() {
324 let err =
325 load_str("beam build { needs [tset] run \"x\" }\nbeam test { run \"y\" }").unwrap_err();
326 assert!(err.message.contains("unknown beam `tset`"));
327 assert_eq!(err.help.as_deref(), Some("did you mean `test`?"));
328 }
329
330 /// The caret lands on the entry that does not resolve, not on the beam
331 /// declaration that happens to contain it — every other diagnostic in
332 /// this workspace is token-precise, and a `needs` entry is a token.
333 #[test]
334 fn unknown_need_points_at_the_offending_entry() {
335 const SOURCE: &str = "beam build { needs [test, tset] run \"x\" }\nbeam test { run \"y\" }";
336
337 let err = load_str(SOURCE).unwrap_err();
338
339 let span = err.span.expect("a declared `needs` entry has a location");
340 assert_eq!(&SOURCE[span.start..span.end], "tset");
341 }
342
343 /// A namespaced entry is underlined whole, alias included.
344 #[test]
345 fn unknown_namespaced_need_points_at_the_whole_reference() {
346 const SOURCE: &str = "beam build { needs [api:tset] run \"x\" }";
347
348 let err = load_str(SOURCE).unwrap_err();
349
350 let span = err.span.expect("a declared `needs` entry has a location");
351 assert_eq!(&SOURCE[span.start..span.end], "api:tset");
352 }
353
354 /// `default` is part of the graph too: naming a beam that does not
355 /// exist must fail the load, not wait until a bare `alba` tries to run
356 /// it.
357 #[test]
358 fn unknown_default_is_rejected_at_its_own_span() {
359 const SOURCE: &str = "default biuld\nbeam build { run \"x\" }";
360
361 let err = load_str(SOURCE).unwrap_err();
362
363 assert!(err.message.contains("unknown beam `biuld`"));
364 assert_eq!(err.help.as_deref(), Some("did you mean `build`?"));
365 let span = err.span.expect("a declared `default` has a location");
366 assert_eq!(&SOURCE[span.start..span.end], "biuld");
367 }
368
369 /// A `default` that resolves is not disturbed by the check.
370 #[test]
371 fn a_default_naming_a_declared_beam_loads() {
372 let project = load_str("default build\nbeam build { run \"x\" }").unwrap();
373
374 assert_eq!(
375 project.default.as_ref().map(|d| d.value.0.as_str()),
376 Some("build")
377 );
378 }
379
380 #[test]
381 fn cycle_reports_exact_path() {
382 let err = load_str(
383 "
384beam build { needs [codegen] run \"a\" }
385beam codegen { needs [build] run \"b\" }
386",
387 )
388 .unwrap_err();
389 assert!(err.message.contains("build → codegen → build"));
390 }
391
392 #[test]
393 fn subgraph_is_transitive_closure() {
394 let project = load_str(
395 "
396beam a { run \"x\" }
397beam b { needs [a] run \"x\" }
398beam c { needs [b] run \"x\" }
399beam unrelated { run \"x\" }
400",
401 )
402 .unwrap();
403 let ids = execution_subgraph(&project, &BeamId("c".into())).unwrap();
404 assert_eq!(sorted(ids), vec!["a", "b", "c"]);
405 }
406
407 /// Edge case called out explicitly: a beam needing itself must report
408 /// `a → a`, not panic or loop forever.
409 #[test]
410 fn self_cycle_reports_itself() {
411 let err = load_str("beam a { needs [a] run \"x\" }").unwrap_err();
412 assert!(err.message.contains("a → a"));
413 }
414
415 /// `validate_graph` checks the whole project, not just whatever is
416 /// reachable from some particular target — a cycle nobody asked to run
417 /// is still a load error.
418 #[test]
419 fn cycle_unreachable_from_any_target_is_still_an_error() {
420 let err = load_str(
421 "
422beam main { run \"x\" }
423beam x { needs [y] run \"x\" }
424beam y { needs [x] run \"x\" }
425",
426 )
427 .unwrap_err();
428 assert!(err.message.contains("x → y → x"));
429 }
430
431 /// A `needs` entry repeated within one beam is not an error, and does
432 /// not duplicate the closure entry it names.
433 #[test]
434 fn duplicate_needs_do_not_duplicate_in_subgraph() {
435 let project = load_str(
436 "
437beam a { run \"x\" }
438beam b { needs [a, a] run \"x\" }
439",
440 )
441 .unwrap();
442 let ids = execution_subgraph(&project, &BeamId("b".into())).unwrap();
443 assert_eq!(sorted(ids), vec!["a", "b"]);
444 }
445
446 /// A target is supplied by whoever asked for the run, not written in
447 /// the Beamfile, so it has no location to underline — drawing a caret
448 /// at the first beam declaration asserted a source position that is
449 /// simply not where the mistake is. The "did you mean...?" help, which
450 /// is the useful half, survives.
451 #[test]
452 fn unknown_target_suggests_closest_without_a_source_location() {
453 let project = load_str("beam build { run \"x\" }").unwrap();
454 let err = execution_subgraph(&project, &BeamId("biuld".into())).unwrap_err();
455 assert!(err.message.contains("unknown beam `biuld`"));
456 assert_eq!(err.help.as_deref(), Some("did you mean `build`?"));
457 assert!(
458 err.span.is_none(),
459 "a target named outside the Beamfile has no span"
460 );
461 }
462
463 /// Guards against the class of bug this crate has already hit once:
464 /// which of two independent cycles gets reported must not depend on
465 /// `HashMap` iteration order, which is randomized per instance (a
466 /// fresh `by_id` map is built on every call). Run repeatedly in one
467 /// process so a flaky dependency on that order would actually surface.
468 #[test]
469 fn cycle_report_is_deterministic_across_runs() {
470 let src = "
471beam a1 { needs [a2] run \"x\" }
472beam a2 { needs [a1] run \"x\" }
473beam b1 { needs [b2] run \"x\" }
474beam b2 { needs [b1] run \"x\" }
475";
476 for _ in 0..20 {
477 let err = load_str(src).unwrap_err();
478 assert!(err.message.contains("a1 → a2 → a1"));
479 }
480 }
481
482 /// Distinguishes correct cycle-path slicing (`path[pos..]`) from the
483 /// bug it guards against (reporting the whole DFS path from whatever
484 /// root started the walk, `path[0..]`): a long, non-cyclic prefix
485 /// leads into the cycle, and none of that prefix's ids may appear in
486 /// the message — only the loop itself. `cycle_reports_exact_path`
487 /// alone doesn't distinguish these, because its DFS root is already
488 /// part of the cycle.
489 #[test]
490 fn cycle_message_excludes_the_non_cyclic_dfs_prefix() {
491 let err = load_str(
492 "
493beam root { needs [mid] run \"x\" }
494beam mid { needs [inner] run \"x\" }
495beam inner { needs [x] run \"x\" }
496beam x { needs [y] run \"x\" }
497beam y { needs [x] run \"x\" }
498",
499 )
500 .unwrap_err();
501 assert!(err.message.contains("x → y → x"));
502 assert!(!err.message.contains("root"));
503 assert!(!err.message.contains("mid"));
504 assert!(!err.message.contains("inner"));
505 }
506
507 /// `d` needs both `b` and `c`, which both need `a`: `a` must appear in
508 /// the closure exactly once, distinguishing the `seen`-guarded BFS
509 /// from one that pushes every `needs` entry unconditionally.
510 /// `duplicate_needs_do_not_duplicate_in_subgraph` only covers the easy
511 /// half (the same id repeated within one beam's own `needs` list) —
512 /// this covers two different beams sharing a dependency.
513 #[test]
514 fn diamond_shaped_needs_deduplicates_shared_dependency() {
515 let project = load_str(
516 "
517beam a { run \"x\" }
518beam b { needs [a] run \"x\" }
519beam c { needs [a] run \"x\" }
520beam d { needs [b, c] run \"x\" }
521",
522 )
523 .unwrap();
524 let ids = execution_subgraph(&project, &BeamId("d".into())).unwrap();
525 assert_eq!(ids.iter().filter(|id| id.0 == "a").count(), 1);
526 assert_eq!(sorted(ids), vec!["a", "b", "c", "d"]);
527 }
528
529 /// A five-level chain, to confirm the BFS actually walks multiple hops
530 /// to completion rather than stopping early — `subgraph_is_transitive_
531 /// closure`'s three levels leave that under-exercised.
532 #[test]
533 fn subgraph_walks_a_deep_chain_completely() {
534 let project = load_str(
535 "
536beam a { run \"x\" }
537beam b { needs [a] run \"x\" }
538beam c { needs [b] run \"x\" }
539beam d { needs [c] run \"x\" }
540beam e { needs [d] run \"x\" }
541",
542 )
543 .unwrap();
544 let ids = execution_subgraph(&project, &BeamId("e".into())).unwrap();
545 assert_eq!(sorted(ids), vec!["a", "b", "c", "d", "e"]);
546 }
547
548 /// A chain declared so that every beam needs the one declared *after*
549 /// it: nothing is marked done ahead of the walk, so cycle detection
550 /// descends the full depth in one go. A recursive walk aborts the
551 /// process here — a stack overflow is not a panic, so no caller can
552 /// catch it — which is why the search keeps its own heap-allocated
553 /// stack.
554 #[test]
555 fn cycle_detection_survives_a_very_deep_chain() {
556 const DEPTH: usize = 10_000;
557
558 let mut source = String::new();
559 for index in 0..DEPTH - 1 {
560 source.push_str(&format!(
561 "beam b{index} {{ needs [b{}] run \"x\" }}\n",
562 index + 1
563 ));
564 }
565 source.push_str(&format!("beam b{} {{ run \"x\" }}\n", DEPTH - 1));
566
567 let project = load_str(&source).unwrap();
568
569 assert_eq!(project.beams.len(), DEPTH);
570 }
571
572 /// The same depth, closed into a cycle by its last beam: the reported
573 /// path must still be exact rather than truncated or reordered by the
574 /// iterative walk.
575 #[test]
576 fn a_cycle_at_the_bottom_of_a_deep_chain_is_reported_exactly() {
577 const DEPTH: usize = 5_000;
578
579 let mut source = String::new();
580 for index in 0..DEPTH - 1 {
581 source.push_str(&format!(
582 "beam b{index} {{ needs [b{}] run \"x\" }}\n",
583 index + 1
584 ));
585 }
586 source.push_str(&format!(
587 "beam b{} {{ needs [b{}] run \"x\" }}\n",
588 DEPTH - 1,
589 DEPTH - 2
590 ));
591
592 let err = load_str(&source).unwrap_err();
593
594 assert!(
595 err.message
596 .contains(&format!("b{} → b{} → b{}", DEPTH - 2, DEPTH - 1, DEPTH - 2))
597 );
598 assert!(!err.message.contains("b0 "));
599 }
600
601 /// A cycle entirely among *imported*, namespaced beams. `detect_cycle` compares `BeamId.0` as
602 /// a plain string, so a `:`-namespaced id is no different from an
603 /// unnamespaced one by inspection — this pins that down with a real
604 /// multi-file fixture instead of leaving it unverified. `api/Beamfile`
605 /// is registered second (`SourceId(1)`), so the error must carry that
606 /// id, not the root's.
607 #[test]
608 fn cycle_among_imported_namespaced_beams_reports_qualified_path() {
609 let dir = tempfile::tempdir().unwrap();
610 write(
611 dir.path().join("Beamfile"),
612 "import \"api/Beamfile\" as api\nbeam all { needs [api:x] run \"echo\" }",
613 );
614 write(
615 dir.path().join("api/Beamfile"),
616 "beam x { needs [y] run \"x\" }\nbeam y { needs [x] run \"y\" }",
617 );
618
619 let err = load_project(&dir.path().join("Beamfile")).unwrap_err();
620
621 assert!(err.error.message.contains("api:x → api:y → api:x"));
622 assert_eq!(err.error.source_id, SourceId(1));
623 }
624
625 /// The `source_id` half of the wiring: `validate_graph` runs after
626 /// every file has finished loading, with no `SourceIdScope` active, so
627 /// it must read the offending beam's own `source` field rather than
628 /// defaulting to the root file's id. `api/Beamfile` here is registered
629 /// second (`SourceId(1)`); a bug that left `source_id` at its default
630 /// would report `SourceId(0)` (the root) instead.
631 #[test]
632 fn unknown_need_in_imported_file_reports_its_own_source_id() {
633 let dir = tempfile::tempdir().unwrap();
634 write(
635 dir.path().join("Beamfile"),
636 "import \"api/Beamfile\" as api\nbeam all { needs [api:build] run \"echo\" }",
637 );
638 write(
639 dir.path().join("api/Beamfile"),
640 "beam build { needs [tset] run \"x\" }\nbeam test { run \"y\" }",
641 );
642
643 let err = load_project(&dir.path().join("Beamfile")).unwrap_err();
644
645 assert!(err.error.message.contains("unknown beam `api:tset`"));
646 assert_eq!(err.error.help.as_deref(), Some("did you mean `api:test`?"));
647 assert_eq!(err.error.source_id, SourceId(1));
648 }
649}