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}