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};
13
14/// True when the crate at `crate_path` exposes a binary *target* named
15/// `wanted` — i.e. `cargo build --bin <wanted>` would resolve. Mirrors
16/// `crate_has_binary_target`'s filesystem-probe approach (no `cargo
17/// metadata` spawn): an explicit `[[bin]] name = "<wanted>"`, the
18/// package-named binary produced by `src/main.rs`, or an auto-discovered
19/// `src/bin/<wanted>.rs`.
20///
21/// Distinct from `crate_has_binary_target`, which answers "does this crate
22/// have ANY binary target". A library crate can carry helper binaries whose
23/// names do not match the crate (e.g. `src/bin/gen.rs` renamed via `[[bin]]`
24/// to `mylib-gen`); such a crate "has a binary target" yet has none named
25/// after itself, so a synthesized default `--bin <crate>` build must be
26/// suppressed rather than handed to cargo, which would hard-error with
27/// `no bin target named '<crate>'` and fail the build/determinism legs.
28///
29/// Shares `crate_has_binary_target`'s documented `autobins = false`
30/// limitation for the `src/bin/` probe. One further filesystem-probe blind
31/// spot: a *nameless* `[[bin]]` with a custom `path` outside `src/bin/` (cargo
32/// derives that target's name from the path stem) is not detected — covering
33/// it would require a `cargo metadata` spawn. Such layouts are rare; declare a
34/// `name` to be seen here.
35pub fn crate_declares_bin(crate_path: &str, wanted: &str) -> bool {
36    let path = Path::new(crate_path);
37    let doc = std::fs::read_to_string(path.join("Cargo.toml"))
38        .ok()
39        .and_then(|c| c.parse::<toml_edit::DocumentMut>().ok());
40    let bin_tables = doc
41        .as_ref()
42        .and_then(|d| d.get("bin"))
43        .and_then(|b| b.as_array_of_tables());
44
45    // 1. Explicit `[[bin]] name = "<wanted>"`.
46    if let Some(arr) = bin_tables
47        && arr
48            .iter()
49            .any(|t| t.get("name").and_then(|v| v.as_str()) == Some(wanted))
50    {
51        return true;
52    }
53
54    // 2. `src/main.rs` yields a binary named after the package; it matches
55    //    when the package name is `wanted` (the default binary name a
56    //    synthesized build resolves to is the crate's own name).
57    if path.join("src/main.rs").exists()
58        && doc
59            .as_ref()
60            .and_then(|d| d.get("package"))
61            .and_then(|p| p.get("name"))
62            .and_then(|v| v.as_str())
63            == Some(wanted)
64    {
65        return true;
66    }
67
68    // 3. Auto-discovered `src/bin/<wanted>.rs` (cargo names the target after
69    //    the file stem) — unless an explicit `[[bin]]` re-paths that file to a
70    //    *different* name, which removes the stem-named target cargo would have
71    //    auto-discovered. Without this guard a crate named after one of its own
72    //    renamed helper files would falsely claim the target and re-trigger the
73    //    doomed `--bin <wanted>`.
74    let stem_file = format!("{wanted}.rs");
75    if path.join("src/bin").join(&stem_file).exists() {
76        let reclaimed_under_other_name = bin_tables.is_some_and(|arr| {
77            arr.iter().any(|t| {
78                t.get("name").and_then(|v| v.as_str()) != Some(wanted)
79                    && t.get("path")
80                        .and_then(|v| v.as_str())
81                        .and_then(|p| Path::new(p).file_name()?.to_str().map(str::to_owned))
82                        .as_deref()
83                        == Some(stem_file.as_str())
84            })
85        });
86        return !reclaimed_under_other_name;
87    }
88    false
89}
90
91/// The build entries the build planner will actually compile for a crate, or
92/// `None` when the crate compiles nothing.
93///
94/// The single source of truth for the "what does this crate produce"
95/// synthesis rule:
96///
97/// - a non-empty `builds:` list is used as-is;
98/// - a crate with no `builds:` that declares a `--bin <crate>` target named
99///   after itself gets a single synthesized default build (`binary = <crate>`,
100///   targets inherited from `defaults.targets`);
101/// - a crate with neither — a library, or one carrying only differently-named
102///   helper bins — compiles nothing and yields `None`.
103///
104/// Target resolution (per-build `targets` overriding `defaults.targets`) is the
105/// caller's concern; this answers only which build entries exist.
106pub fn planned_builds(krate: &CrateConfig) -> Option<Vec<BuildConfig>> {
107    match krate.builds.as_deref() {
108        Some(b) if !b.is_empty() => Some(b.to_vec()),
109        _ => crate_declares_bin(&krate.path, &krate.name).then(|| {
110            vec![BuildConfig {
111                binary: Some(krate.name.clone()),
112                ..Default::default()
113            }]
114        }),
115    }
116}
117
118/// Whether a build entry yields a shippable artifact (compiled binary or a
119/// staged prebuilt). A `defaults.builds:` template materialized onto a library
120/// crate carries `binary: None` and resolves no default `--bin <crate>`, so it
121/// compiles nothing — the build planner skips it, and every target/toolchain
122/// enumeration must skip it identically or it over-reports.
123pub fn build_produces(krate: &CrateConfig, build: &BuildConfig) -> bool {
124    matches!(build.builder, Some(BuilderKind::Prebuilt))
125        || build.binary.is_some()
126        || crate_declares_bin(&krate.path, &krate.name)
127}
128
129/// A build entry's static id, tagged with whether the caller must render it
130/// before comparing against a configured id list.
131///
132/// Mirrors `stage-build::run_helpers::artifact_meta`'s exact precedence: an
133/// explicit `build.id` is stamped onto the `Binary` artifact's `id` metadata
134/// byte-for-byte (`run.rs` clones it raw, never through
135/// [`crate::context::Context::render_template`]); only the `binary`-fallback
136/// id (`build.binary`, or the crate name when `binary` is unset too) is ever
137/// rendered, once per target, before it becomes the artifact's `id`. A
138/// caller that renders an `Explicit` id anyway would match configured id
139/// lists production itself never matches — this module has no `Context` to
140/// render through, so the two cases are kept distinguishable rather than
141/// collapsed into one already-resolved string.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum BuildId {
144    /// `build.id` was set; compare this string verbatim, never rendered.
145    Explicit(String),
146    /// `build.id` was unset; this is the unrendered `binary`-fallback source
147    /// (`build.binary` or the crate name). Callers with a live `Context`
148    /// must render it the same way `stage-build::run.rs` renders
149    /// `binary_name` before comparing or displaying it.
150    BinaryFallback(String),
151}
152
153impl BuildId {
154    /// The raw string this variant carries, unrendered. Correct for
155    /// `Explicit` (which is never templated in production); a caller
156    /// needing the true resolved value of a `BinaryFallback` must render it
157    /// through a [`crate::context::Context`] first.
158    pub fn raw(&self) -> &str {
159        match self {
160            BuildId::Explicit(s) | BuildId::BinaryFallback(s) => s,
161        }
162    }
163}
164
165/// One planned build entry's static identity + the target triples it
166/// contributes, as resolved by [`crate_build_target_entries`].
167pub struct CrateBuildTargets {
168    pub id: BuildId,
169    pub targets: Vec<String>,
170}
171
172/// [`crate_target_list`], but callers can additionally veto a build entry
173/// (e.g. a truthy `BuildConfig.skip`) and get each surviving build's static
174/// id alongside its target triples, not just the flattened union. THE single
175/// source of truth for crate target enumeration — [`crate_target_list`] and
176/// `stage-publish::publisher_helpers::crate_build_targets` both compose this
177/// rather than re-deriving the synthesis rule, so they cannot drift.
178pub fn crate_build_target_entries(
179    krate: &CrateConfig,
180    default_targets: &[String],
181    mut is_skipped: impl FnMut(&BuildConfig) -> bool,
182) -> Vec<CrateBuildTargets> {
183    let Some(builds) = planned_builds(krate) else {
184        return Vec::new();
185    };
186    let mut out: Vec<CrateBuildTargets> = Vec::new();
187    for build in &builds {
188        if !build_produces(krate, build) || is_skipped(build) {
189            continue;
190        }
191        let chosen: &[String] = match build.targets.as_deref() {
192            Some(ts) => ts,
193            None => default_targets,
194        };
195        let id = match build.id.clone() {
196            Some(id) => BuildId::Explicit(id),
197            None => {
198                BuildId::BinaryFallback(build.binary.clone().unwrap_or_else(|| krate.name.clone()))
199            }
200        };
201        out.push(CrateBuildTargets {
202            id,
203            targets: chosen.to_vec(),
204        });
205    }
206    out
207}
208
209/// The de-duplicated, order-preserving list of target triples a crate's builds
210/// will actually produce: planner synthesis ([`planned_builds`]) + the compile/
211/// artifact gate ([`build_produces`]) + per-build `targets:` override of
212/// `default_targets`. THE single source of truth for crate target enumeration.
213pub fn crate_target_list(krate: &CrateConfig, default_targets: &[String]) -> Vec<String> {
214    let mut out: Vec<String> = Vec::new();
215    for entry in crate_build_target_entries(krate, default_targets, |_| false) {
216        for t in entry.targets {
217            if !out.contains(&t) {
218                out.push(t);
219            }
220        }
221    }
222    out
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    /// Write a minimal crate skeleton with the given Cargo.toml + optional
230    /// `src/main.rs` so the filesystem probes have something to read.
231    fn crate_dir(cargo_toml: &str, with_main: bool) -> tempfile::TempDir {
232        let dir = tempfile::tempdir().unwrap();
233        std::fs::write(dir.path().join("Cargo.toml"), cargo_toml).unwrap();
234        if with_main {
235            std::fs::create_dir_all(dir.path().join("src")).unwrap();
236            std::fs::write(dir.path().join("src/main.rs"), "fn main() {}\n").unwrap();
237        }
238        dir
239    }
240
241    fn krate_at(name: &str, path: &str, builds: Option<Vec<BuildConfig>>) -> CrateConfig {
242        CrateConfig {
243            name: name.to_string(),
244            path: path.to_string(),
245            builds,
246            ..Default::default()
247        }
248    }
249
250    #[test]
251    fn build_produces_false_for_binary_none_library_crate() {
252        // Library crate (no src/main.rs, no [[bin]]) carrying a materialized
253        // `binary: None` build — the planner skips it, so build_produces is false.
254        let dir = crate_dir("[package]\nname = \"lib\"\nversion = \"0.0.0\"\n", false);
255        let krate = krate_at("lib", dir.path().to_str().unwrap(), None);
256        let build = BuildConfig::default();
257        assert!(!build_produces(&krate, &build));
258    }
259
260    #[test]
261    fn build_produces_true_for_prebuilt() {
262        let dir = crate_dir("[package]\nname = \"lib\"\nversion = \"0.0.0\"\n", false);
263        let krate = krate_at("lib", dir.path().to_str().unwrap(), None);
264        let build = BuildConfig {
265            builder: Some(BuilderKind::Prebuilt),
266            ..Default::default()
267        };
268        assert!(build_produces(&krate, &build));
269    }
270
271    #[test]
272    fn build_produces_true_for_explicit_binary() {
273        let dir = crate_dir("[package]\nname = \"lib\"\nversion = \"0.0.0\"\n", false);
274        let krate = krate_at("lib", dir.path().to_str().unwrap(), None);
275        let build = BuildConfig {
276            binary: Some("app".to_string()),
277            ..Default::default()
278        };
279        assert!(build_produces(&krate, &build));
280    }
281
282    #[test]
283    fn build_produces_true_for_declared_bin() {
284        // src/main.rs + package name == crate name → declares a `--bin <crate>`.
285        let dir = crate_dir("[package]\nname = \"app\"\nversion = \"0.0.0\"\n", true);
286        let krate = krate_at("app", dir.path().to_str().unwrap(), None);
287        let build = BuildConfig::default();
288        assert!(build_produces(&krate, &build));
289    }
290
291    #[test]
292    fn crate_target_list_empty_for_library_with_materialized_binary_none_build() {
293        // A library crate that inherited a `defaults.builds` template carries a
294        // build with `binary: None`; with no `--bin <crate>` target the gate
295        // drops it, so the crate produces no targets.
296        let dir = crate_dir("[package]\nname = \"lib\"\nversion = \"0.0.0\"\n", false);
297        let krate = krate_at(
298            "lib",
299            dir.path().to_str().unwrap(),
300            Some(vec![BuildConfig::default()]),
301        );
302        let defaults = vec!["x86_64-unknown-linux-gnu".to_string()];
303        assert!(crate_target_list(&krate, &defaults).is_empty());
304    }
305
306    #[test]
307    fn crate_target_list_uses_default_targets_for_declared_bin() {
308        let dir = crate_dir("[package]\nname = \"app\"\nversion = \"0.0.0\"\n", true);
309        let krate = krate_at("app", dir.path().to_str().unwrap(), None);
310        let defaults = vec![
311            "x86_64-unknown-linux-gnu".to_string(),
312            "aarch64-unknown-linux-gnu".to_string(),
313        ];
314        assert_eq!(crate_target_list(&krate, &defaults), defaults);
315    }
316}