Skip to main content

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}