anodizer_core/build_plan.rs
1//! Build-synthesis single source of truth: which build entries a crate
2//! actually compiles, and over which target triples.
3//!
4//! Every target and toolchain enumeration MUST resolve through these helpers
5//! rather than re-deriving the synthesis rule, so independent call sites cannot
6//! drift on which crates build and what they produce. The build planner's
7//! per-build compile gate is the reference behavior; [`build_produces`] mirrors
8//! it and [`crate_target_list`] composes it with [`planned_builds`].
9
10use std::path::Path;
11
12use crate::config::{BuildConfig, BuilderKind, CrateConfig};
13use crate::context::Context;
14
15/// True when the crate at `crate_path` exposes a binary *target* named
16/// `wanted` — i.e. `cargo build --bin <wanted>` would resolve. Mirrors
17/// `crate_has_binary_target`'s filesystem-probe approach (no `cargo
18/// metadata` spawn): an explicit `[[bin]] name = "<wanted>"`, the
19/// package-named binary produced by `src/main.rs`, or an auto-discovered
20/// `src/bin/<wanted>.rs`.
21///
22/// Distinct from `crate_has_binary_target`, which answers "does this crate
23/// have ANY binary target". A library crate can carry helper binaries whose
24/// names do not match the crate (e.g. `src/bin/gen.rs` renamed via `[[bin]]`
25/// to `mylib-gen`); such a crate "has a binary target" yet has none named
26/// after itself, so a synthesized default `--bin <crate>` build must be
27/// suppressed rather than handed to cargo, which would hard-error with
28/// `no bin target named '<crate>'` and fail the build/determinism legs.
29///
30/// Shares `crate_has_binary_target`'s documented `autobins = false`
31/// limitation for the `src/bin/` probe. One further filesystem-probe blind
32/// spot: a *nameless* `[[bin]]` with a custom `path` outside `src/bin/` (cargo
33/// derives that target's name from the path stem) is not detected — covering
34/// it would require a `cargo metadata` spawn. Such layouts are rare; declare a
35/// `name` to be seen here.
36pub fn crate_declares_bin(crate_path: &str, wanted: &str) -> bool {
37 let path = Path::new(crate_path);
38 let doc = std::fs::read_to_string(path.join("Cargo.toml"))
39 .ok()
40 .and_then(|c| c.parse::<toml_edit::DocumentMut>().ok());
41 let bin_tables = doc
42 .as_ref()
43 .and_then(|d| d.get("bin"))
44 .and_then(|b| b.as_array_of_tables());
45
46 // 1. Explicit `[[bin]] name = "<wanted>"`.
47 if let Some(arr) = bin_tables
48 && arr
49 .iter()
50 .any(|t| t.get("name").and_then(|v| v.as_str()) == Some(wanted))
51 {
52 return true;
53 }
54
55 // 2. `src/main.rs` yields a binary named after the package; it matches
56 // when the package name is `wanted` (the default binary name a
57 // synthesized build resolves to is the crate's own name).
58 if path.join("src/main.rs").exists()
59 && doc
60 .as_ref()
61 .and_then(|d| d.get("package"))
62 .and_then(|p| p.get("name"))
63 .and_then(|v| v.as_str())
64 == Some(wanted)
65 {
66 return true;
67 }
68
69 // 3. Auto-discovered `src/bin/<wanted>.rs` (cargo names the target after
70 // the file stem) — unless an explicit `[[bin]]` re-paths that file to a
71 // *different* name, which removes the stem-named target cargo would have
72 // auto-discovered. Without this guard a crate named after one of its own
73 // renamed helper files would falsely claim the target and re-trigger the
74 // doomed `--bin <wanted>`.
75 let stem_file = format!("{wanted}.rs");
76 if path.join("src/bin").join(&stem_file).exists() {
77 let reclaimed_under_other_name = bin_tables.is_some_and(|arr| {
78 arr.iter().any(|t| {
79 t.get("name").and_then(|v| v.as_str()) != Some(wanted)
80 && t.get("path")
81 .and_then(|v| v.as_str())
82 .and_then(|p| Path::new(p).file_name()?.to_str().map(str::to_owned))
83 .as_deref()
84 == Some(stem_file.as_str())
85 })
86 });
87 return !reclaimed_under_other_name;
88 }
89 false
90}
91
92/// The build entries the build planner will actually compile for a crate, or
93/// `None` when the crate compiles nothing.
94///
95/// The single source of truth for the "what does this crate produce"
96/// synthesis rule:
97///
98/// - a non-empty `builds:` list is used as-is;
99/// - a crate with no `builds:` that declares a `--bin <crate>` target named
100/// after itself gets a single synthesized default build whose binary is
101/// whatever [`binary_or_crate_name`] resolves for a defaulted entry, with
102/// targets inherited from `defaults.targets`;
103/// - a crate with neither — a library, or one carrying only differently-named
104/// helper bins — compiles nothing and yields `None`.
105///
106/// Target resolution (per-build `targets` overriding `defaults.targets`) is the
107/// caller's concern; this answers only which build entries exist.
108pub fn planned_builds(krate: &CrateConfig) -> Option<Vec<BuildConfig>> {
109 match krate.builds.as_deref() {
110 Some(b) if !b.is_empty() => Some(b.to_vec()),
111 _ => crate_declares_bin(&krate.path, &krate.name).then(|| {
112 vec![BuildConfig {
113 binary: Some(binary_or_crate_name(krate, &BuildConfig::default())),
114 ..Default::default()
115 }]
116 }),
117 }
118}
119
120/// Whether a build entry yields a shippable artifact (compiled binary or a
121/// staged prebuilt). A `defaults.builds:` template materialized onto a library
122/// crate carries `binary: None` and resolves no default `--bin <crate>`, so it
123/// compiles nothing — the build planner skips it, and every target/toolchain
124/// enumeration must skip it identically or it over-reports.
125pub fn build_produces(krate: &CrateConfig, build: &BuildConfig) -> bool {
126 matches!(build.builder, Some(BuilderKind::Prebuilt))
127 || build.binary.is_some()
128 || crate_declares_bin(&krate.path, &krate.name)
129}
130
131/// A build entry's static id, tagged with whether the caller must render it
132/// before comparing against a configured id list.
133///
134/// Mirrors `stage-build::run_helpers::artifact_meta`'s exact precedence: an
135/// explicit `build.id` is stamped onto the `Binary` artifact's `id` metadata
136/// byte-for-byte (`run.rs` clones it raw, never through
137/// [`crate::context::Context::render_template`]); only the `binary`-fallback
138/// id (`build.binary`, or the crate name when `binary` is unset too) is ever
139/// rendered, once per target, before it becomes the artifact's `id`. A
140/// caller that renders an `Explicit` id anyway would match configured id
141/// lists production itself never matches — this module has no `Context` to
142/// render through, so the two cases are kept distinguishable rather than
143/// collapsed into one already-resolved string.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum BuildId {
146 /// `build.id` was set; compare this string verbatim, never rendered.
147 Explicit(String),
148 /// `build.id` was unset; this is the unrendered `binary`-fallback source
149 /// (`build.binary` or the crate name). Callers with a live `Context`
150 /// must render it the same way `stage-build::run.rs` renders
151 /// `binary_name` before comparing or displaying it.
152 BinaryFallback(String),
153}
154
155impl BuildId {
156 /// The raw string this variant carries, unrendered. Correct for
157 /// `Explicit` (which is never templated in production); a caller
158 /// needing the true resolved value of a `BinaryFallback` must render it
159 /// through a [`crate::context::Context`] first.
160 pub fn raw(&self) -> &str {
161 match self {
162 BuildId::Explicit(s) | BuildId::BinaryFallback(s) => s,
163 }
164 }
165}
166
167/// One planned build entry's static identity + the target triples it
168/// contributes, as resolved by [`crate_build_target_entries`].
169pub struct CrateBuildTargets {
170 pub id: BuildId,
171 /// The binary the entry compiles: its `binary:`, else the crate's own
172 /// `[[bin]]` name. Unrendered — a `binary:` that is itself a template must
173 /// be rendered before it is compared or displayed, the same way
174 /// [`BuildId::BinaryFallback`] must.
175 pub binary: String,
176 pub targets: Vec<String>,
177}
178
179/// [`crate_target_list`], but callers can additionally veto a build entry
180/// (e.g. a truthy `BuildConfig.skip`) and get each surviving build's static
181/// id alongside its target triples, not just the flattened union. THE single
182/// source of truth for crate target enumeration — [`crate_target_list`] and
183/// `stage-publish::publisher_helpers::crate_build_targets` both compose this
184/// rather than re-deriving the synthesis rule, so they cannot drift.
185pub fn crate_build_target_entries(
186 krate: &CrateConfig,
187 default_targets: &[String],
188 mut is_skipped: impl FnMut(&BuildConfig) -> bool,
189) -> Vec<CrateBuildTargets> {
190 let Some(builds) = planned_builds(krate) else {
191 return Vec::new();
192 };
193 let mut out: Vec<CrateBuildTargets> = Vec::new();
194 for build in &builds {
195 if !build_produces(krate, build) || is_skipped(build) {
196 continue;
197 }
198 let chosen: &[String] = match build.targets.as_deref() {
199 Some(ts) => ts,
200 None => default_targets,
201 };
202 out.push(CrateBuildTargets {
203 id: static_build_id(krate, build),
204 binary: binary_or_crate_name(krate, build),
205 targets: chosen.to_vec(),
206 });
207 }
208 out
209}
210
211/// A build entry's static id, the value `stage-build` stamps onto the
212/// artifacts it produces: an explicit `build.id`, else the `binary`-fallback
213/// (see [`BuildId`]).
214fn static_build_id(krate: &CrateConfig, build: &BuildConfig) -> BuildId {
215 match build.id.clone() {
216 Some(id) => BuildId::Explicit(id),
217 None => BuildId::BinaryFallback(binary_or_crate_name(krate, build)),
218 }
219}
220
221/// The binary a build entry compiles: its `binary:`, else the crate's own
222/// `[[bin]]` target, which is what an entry omitting `binary:` builds. THE
223/// spelling of that fallback — a call site re-deriving it drifts the moment
224/// the rule does.
225pub fn binary_or_crate_name(krate: &CrateConfig, build: &BuildConfig) -> String {
226 build.binary.clone().unwrap_or_else(|| krate.name.clone())
227}
228
229/// The binary a crate's release is named after when no archive selector
230/// narrows the candidates: the first build entry the run actually RELEASES —
231/// one that produces an artifact and that `is_skipped` does not veto — else
232/// the crate's own `[[bin]]` name. The last resort of every "what is this
233/// crate's binary called" chain — the snap name, the installed-version probe.
234///
235/// Taking the first CONFIGURED build instead names the release after an entry
236/// the run never compiles: a `defaults.builds:` template materialized onto a
237/// crate carries `binary: None` and resolves no default `--bin <crate>`, so it
238/// produces nothing while still sitting first in the list; a `skip:` build
239/// compiles nothing for the same reason. Pass [`build_is_skipped`] against a
240/// live context for `is_skipped`.
241pub fn crate_primary_binary_name(
242 krate: &CrateConfig,
243 mut is_skipped: impl FnMut(&BuildConfig) -> bool,
244) -> String {
245 planned_builds(krate)
246 .and_then(|builds| {
247 builds
248 .iter()
249 .find(|b| build_produces(krate, b) && !is_skipped(b))
250 .map(|b| binary_or_crate_name(krate, b))
251 })
252 .unwrap_or_else(|| binary_or_crate_name(krate, &BuildConfig::default()))
253}
254
255/// Whether an archive's `ids:` filter selects a build entry's id. The
256/// static-id half of the artifact-level `matches_id_filter`, which judges the
257/// produced artifacts by that same id; an absent or empty list selects every
258/// build.
259fn archive_selects_id(id: &str, archive_ids: Option<&[String]>) -> bool {
260 match archive_ids {
261 None | Some([]) => true,
262 Some(ids) => ids.iter().any(|want| want == id),
263 }
264}
265
266/// Whether an archive's `binaries:` allow-list packs a build entry's binary.
267/// Mirrors the archive stage's own per-target filter, where an EMPTY list
268/// selects nothing (unlike `ids:`, where it selects everything).
269fn archive_packs_binary(binary: &str, archive_binaries: Option<&[String]>) -> bool {
270 match archive_binaries {
271 None => true,
272 Some(names) => names.iter().any(|want| want == binary),
273 }
274}
275
276/// Whether a build entry's `skip:` evaluates truthy. An expression that fails
277/// to render does not skip the build — the lenient reading, shared with
278/// preflight's `entry_inactive`; the build stage itself propagates such a
279/// render error. THE spelling of that gate for callers supplying the
280/// `is_skipped` predicate [`crate_build_target_entries`],
281/// [`crate_target_list`] and [`crate_primary_binary_name`] take.
282pub fn build_is_skipped(
283 build: &BuildConfig,
284 render: impl Fn(&str) -> anyhow::Result<String>,
285) -> bool {
286 try_build_is_skipped(build, render).unwrap_or(false)
287}
288
289/// Whether a build entry's `skip:` evaluates truthy, keeping a render error as
290/// an error.
291///
292/// The planner surfaces a broken `skip:` expression instead of building the
293/// entry it could not decide about; [`build_is_skipped`] is the lenient
294/// reading of the same rule for callers that only need the predicate.
295pub fn try_build_is_skipped(
296 build: &BuildConfig,
297 render: impl Fn(&str) -> anyhow::Result<String>,
298) -> anyhow::Result<bool> {
299 match build.skip.as_ref() {
300 Some(s) => s.try_evaluates_to_true(render),
301 None => Ok(false),
302 }
303}
304
305/// [`build_is_skipped`] bound to a live context — the `skip:` gate with the
306/// context's own template renderer already supplied.
307///
308/// Every consumer of the "which builds does THIS run release" question needs
309/// the same adapter, so it is spelled here rather than at each call site:
310/// pass the result straight to [`crate_primary_binary_name`],
311/// [`crate_target_list`] or [`crate_build_target_entries`].
312pub fn skipped_in(ctx: &Context) -> impl Fn(&BuildConfig) -> bool + '_ {
313 move |build| build_is_skipped(build, |t| ctx.render_template(t))
314}
315
316/// [`crate_primary_binary_name`] resolved against a live context.
317pub fn crate_primary_binary_name_in(ctx: &Context, krate: &CrateConfig) -> String {
318 crate_primary_binary_name(krate, skipped_in(ctx))
319}
320
321/// [`crate_target_list`] resolved against a live context.
322pub fn crate_target_list_in(
323 ctx: &Context,
324 krate: &CrateConfig,
325 default_targets: &[String],
326) -> Vec<String> {
327 crate_target_list(krate, default_targets, skipped_in(ctx))
328}
329
330/// The binary an archive's assets are named after on one target — the value
331/// bound to `{{ .Binary }}` while rendering its `name_template`.
332///
333/// The archive stage names each asset after the first binary the entry packs
334/// FOR THAT TARGET, and it narrows its candidates four ways before picking it:
335/// the entry's `ids:` filter, the per-target grouping (each build contributes
336/// only the targets its own `targets:` names), the entry's `binaries:`
337/// allow-list, and each build's `skip:`. A crate that splits its builds by
338/// platform therefore has a different `{{ .Binary }}` per target. Every
339/// derived-name consumer (the cargo-binstall `pkg_url`, the `curl | sh`
340/// installer's asset table) must apply all four the same way or it publishes a
341/// URL the release never uploaded. Falls back to the crate's own `[[bin]]`
342/// name, which is what a build entry declaring no `binary:` compiles.
343///
344/// `render` resolves templated config values against the caller's live
345/// context.
346pub fn archive_binary_name(
347 krate: &CrateConfig,
348 archive_ids: Option<&[String]>,
349 archive_binaries: Option<&[String]>,
350 target: &str,
351 default_targets: &[String],
352 render: impl Fn(&str) -> anyhow::Result<String>,
353) -> String {
354 crate_build_target_entries(krate, default_targets, |build| {
355 build_is_skipped(build, &render)
356 })
357 .into_iter()
358 .find_map(|entry| {
359 // `stage-build` renders the binary name, and the `binary`-fallback id
360 // it derives from it, once per target before stamping either on the
361 // artifact the archive stage then filters — so a `binary:` that is
362 // itself a template is matched and displayed RENDERED. An explicit
363 // `build.id` is stamped verbatim and must never be rendered.
364 let binary = render(&entry.binary).unwrap_or(entry.binary);
365 let id = match &entry.id {
366 BuildId::Explicit(raw) => raw.clone(),
367 BuildId::BinaryFallback(raw) => render(raw).unwrap_or_else(|_| raw.clone()),
368 };
369 (entry.targets.iter().any(|t| t == target)
370 && archive_selects_id(&id, archive_ids)
371 && archive_packs_binary(&binary, archive_binaries))
372 .then_some(binary)
373 })
374 .unwrap_or_else(|| binary_or_crate_name(krate, &BuildConfig::default()))
375}
376
377/// The de-duplicated, order-preserving list of target triples a crate's builds
378/// will actually produce: planner synthesis ([`planned_builds`]) + the compile/
379/// artifact gate ([`build_produces`]) + `is_skipped` + per-build `targets:`
380/// override of `default_targets`. THE single source of truth for crate target
381/// enumeration.
382///
383/// `is_skipped` vetoes a build entry the same way it does in
384/// [`crate_build_target_entries`] — pass [`build_is_skipped`] against a live
385/// context to enumerate the triples THIS run releases, or `|_| false` to
386/// enumerate every configured triple (what a config-time check wants, since a
387/// `skip:` expression can resolve differently on the machine that releases).
388pub fn crate_target_list(
389 krate: &CrateConfig,
390 default_targets: &[String],
391 is_skipped: impl FnMut(&BuildConfig) -> bool,
392) -> Vec<String> {
393 let mut out: Vec<String> = Vec::new();
394 for entry in crate_build_target_entries(krate, default_targets, is_skipped) {
395 for t in entry.targets {
396 if !out.contains(&t) {
397 out.push(t);
398 }
399 }
400 }
401 out
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 /// Write a minimal crate skeleton with the given Cargo.toml + optional
409 /// `src/main.rs` so the filesystem probes have something to read.
410 fn crate_dir(cargo_toml: &str, with_main: bool) -> tempfile::TempDir {
411 let dir = tempfile::tempdir().unwrap();
412 std::fs::write(dir.path().join("Cargo.toml"), cargo_toml).unwrap();
413 if with_main {
414 std::fs::create_dir_all(dir.path().join("src")).unwrap();
415 std::fs::write(dir.path().join("src/main.rs"), "fn main() {}\n").unwrap();
416 }
417 dir
418 }
419
420 fn krate_at(name: &str, path: &str, builds: Option<Vec<BuildConfig>>) -> CrateConfig {
421 CrateConfig {
422 name: name.to_string(),
423 path: path.to_string(),
424 builds,
425 ..Default::default()
426 }
427 }
428
429 /// The synthesized default build must name its binary through the same
430 /// helper every other site derives a binary name from. Spelled a second
431 /// time here, the synthesized entry stops moving when the fallback rule
432 /// moves, and the name the planner compiles diverges from every name
433 /// derived from it.
434 #[test]
435 fn the_synthesized_default_build_names_the_binary_the_helper_names() {
436 let dir = crate_dir("[package]\nname = \"my_app\"\nversion = \"0.0.0\"\n", true);
437 let krate = krate_at("my_app", dir.path().to_str().unwrap(), None);
438 let builds = planned_builds(&krate).expect("a crate declaring its own bin plans a build");
439 assert_eq!(
440 builds.iter().map(|b| b.binary.clone()).collect::<Vec<_>>(),
441 vec![Some(binary_or_crate_name(&krate, &BuildConfig::default()))],
442 );
443 }
444
445 #[test]
446 fn build_produces_false_for_binary_none_library_crate() {
447 // Library crate (no src/main.rs, no [[bin]]) carrying a materialized
448 // `binary: None` build — the planner skips it, so build_produces is false.
449 let dir = crate_dir("[package]\nname = \"lib\"\nversion = \"0.0.0\"\n", false);
450 let krate = krate_at("lib", dir.path().to_str().unwrap(), None);
451 let build = BuildConfig::default();
452 assert!(!build_produces(&krate, &build));
453 }
454
455 #[test]
456 fn build_produces_true_for_prebuilt() {
457 let dir = crate_dir("[package]\nname = \"lib\"\nversion = \"0.0.0\"\n", false);
458 let krate = krate_at("lib", dir.path().to_str().unwrap(), None);
459 let build = BuildConfig {
460 builder: Some(BuilderKind::Prebuilt),
461 ..Default::default()
462 };
463 assert!(build_produces(&krate, &build));
464 }
465
466 #[test]
467 fn build_produces_true_for_explicit_binary() {
468 let dir = crate_dir("[package]\nname = \"lib\"\nversion = \"0.0.0\"\n", false);
469 let krate = krate_at("lib", dir.path().to_str().unwrap(), None);
470 let build = BuildConfig {
471 binary: Some("app".to_string()),
472 ..Default::default()
473 };
474 assert!(build_produces(&krate, &build));
475 }
476
477 #[test]
478 fn build_produces_true_for_declared_bin() {
479 // src/main.rs + package name == crate name → declares a `--bin <crate>`.
480 let dir = crate_dir("[package]\nname = \"app\"\nversion = \"0.0.0\"\n", true);
481 let krate = krate_at("app", dir.path().to_str().unwrap(), None);
482 let build = BuildConfig::default();
483 assert!(build_produces(&krate, &build));
484 }
485
486 #[test]
487 fn crate_target_list_empty_for_library_with_materialized_binary_none_build() {
488 // A library crate that inherited a `defaults.builds` template carries a
489 // build with `binary: None`; with no `--bin <crate>` target the gate
490 // drops it, so the crate produces no targets.
491 let dir = crate_dir("[package]\nname = \"lib\"\nversion = \"0.0.0\"\n", false);
492 let krate = krate_at(
493 "lib",
494 dir.path().to_str().unwrap(),
495 Some(vec![BuildConfig::default()]),
496 );
497 let defaults = vec!["x86_64-unknown-linux-gnu".to_string()];
498 assert!(crate_target_list(&krate, &defaults, |_| false).is_empty());
499 }
500
501 /// A build entry's `skip:` is evaluated in two shapes: leniently, where a
502 /// render failure means "not skipped" ([`build_is_skipped`]), and
503 /// strictly, where it is an error the caller propagates
504 /// ([`try_build_is_skipped`]). Each shape has ONE spelling, and a
505 /// hand-written copy of either is how a caller drifts from the planner it
506 /// is supposed to mirror — which is what `cross_requirements` did until it
507 /// routed here.
508 ///
509 /// The named owners each read `build.skip` for a reason that is not a
510 /// second copy of a gate:
511 ///
512 /// | Owner | Why it reads the field directly |
513 /// |---|---|
514 /// | `try_build_is_skipped` | it IS the strict gate; the callers that propagate a render error (`build_skipped`, `plan_prebuilt_build`, `plan_build_jobs`) route through it |
515 /// | `configured_build_targets` (`env_preflight.rs`) | hands the field to the shared `entry_inactive` predicate |
516 #[test]
517 fn every_build_skip_read_belongs_to_a_named_owner() {
518 use crate::test_helpers::test_sources::{
519 function_bodies, production_half, workspace_production_sources,
520 };
521
522 const OWNERS: &[&str] = &["try_build_is_skipped", "configured_build_targets"];
523
524 let sources = workspace_production_sources();
525
526 let mut strays: Vec<String> = Vec::new();
527 for source in &sources {
528 let text = std::fs::read_to_string(source).expect("read source");
529 for body in function_bodies(production_half(&text)) {
530 // Whitespace around `.` is erased so a receiver split across
531 // lines reads the same as one written inline.
532 let flat = body
533 .replace('\n', " ")
534 .split('.')
535 .map(str::trim)
536 .collect::<Vec<_>>()
537 .join(".");
538 if !flat.contains("build.skip") {
539 continue;
540 }
541 let name = body
542 .lines()
543 .next()
544 .and_then(|l| l.split("fn ").nth(1))
545 .and_then(|l| l.split(['(', '<', ' ']).next())
546 .unwrap_or("<unnamed>")
547 .to_string();
548 if !OWNERS.contains(&name.as_str()) {
549 strays.push(format!("{}: {name}", source.display()));
550 }
551 }
552 }
553 assert!(
554 strays.is_empty(),
555 "a build entry's skip: is read by one of {OWNERS:?}; these read it \
556 themselves and will drift from the planner: {strays:#?}"
557 );
558 }
559
560 /// The adapter that turns a live [`Context`] into the lenient `skip:` gate
561 /// is [`skipped_in`], and nothing else: a hand-written
562 /// `build_is_skipped(build, |t| ctx.render_template(t))` at a consumer is
563 /// how "which builds does this run release" drifts from the planner it
564 /// mirrors. Six consumers spelled it themselves before they routed here.
565 /// The strict gate is exempt: every caller of [`try_build_is_skipped`]
566 /// names the entry in its own error context, so there is nothing to share.
567 #[test]
568 fn the_context_skip_adapter_is_spelled_once() {
569 use crate::test_helpers::test_sources::{
570 function_bodies, production_half, workspace_production_sources,
571 };
572
573 let sources = workspace_production_sources();
574
575 let mut strays: Vec<String> = Vec::new();
576 for source in &sources {
577 let text = std::fs::read_to_string(source).expect("read source");
578 for body in function_bodies(production_half(&text)) {
579 // `try_build_is_skipped` is the STRICT gate: each caller
580 // wraps it in its own error context, so it has no single
581 // context adapter and its call sites are not strays.
582 let lenient_calls = body.matches("build_is_skipped(").count()
583 - body.matches("try_build_is_skipped(").count();
584 if lenient_calls == 0 || !body.contains("render_template(") {
585 continue;
586 }
587 let name = body
588 .lines()
589 .next()
590 .and_then(|l| l.split("fn ").nth(1))
591 .and_then(|l| l.split(['(', '<', ' ']).next())
592 .unwrap_or("<unnamed>")
593 .to_string();
594 if name != "skipped_in" {
595 strays.push(format!("{}: {name}", source.display()));
596 }
597 }
598 }
599 assert!(
600 strays.is_empty(),
601 "bind the skip gate to a context through `build_plan::skipped_in`, \
602 not a local closure: {strays:#?}"
603 );
604 }
605
606 /// The planner must not build an entry whose `skip:` it could not
607 /// evaluate, so the strict gate keeps the render error the lenient gate
608 /// reads as "not skipped".
609 #[test]
610 fn the_strict_skip_gate_keeps_a_render_error_the_lenient_one_swallows() {
611 use crate::config::StringOrBool;
612
613 let build = BuildConfig {
614 skip: Some(StringOrBool::String("{{ missing_var }}".to_string())),
615 ..Default::default()
616 };
617 let render = |_: &str| anyhow::bail!("render failed");
618
619 let err = try_build_is_skipped(&build, render)
620 .expect_err("a skip: that cannot render is an error, not a false");
621 assert!(err.to_string().contains("render failed"), "got: {err}");
622 assert!(!build_is_skipped(&build, render));
623 }
624
625 #[test]
626 fn crate_target_list_uses_default_targets_for_declared_bin() {
627 let dir = crate_dir("[package]\nname = \"app\"\nversion = \"0.0.0\"\n", true);
628 let krate = krate_at("app", dir.path().to_str().unwrap(), None);
629 let defaults = vec![
630 "x86_64-unknown-linux-gnu".to_string(),
631 "aarch64-unknown-linux-gnu".to_string(),
632 ];
633 assert_eq!(crate_target_list(&krate, &defaults, |_| false), defaults);
634 }
635}