Skip to main content

anodizer_core/
partial.rs

1//! Partial build target resolution for split/merge CI fan-out.
2//!
3//! Partial-build target resolution — resolves which build targets
4//! to include when running in split mode.
5
6use anyhow::{Context as _, Result};
7
8use crate::EnvSource;
9use crate::config::PartialConfig;
10use crate::target;
11
12// ---------------------------------------------------------------------------
13// PartialTarget — resolved target filter
14// ---------------------------------------------------------------------------
15
16/// A resolved partial build target filter.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum PartialTarget {
19    /// Exact target triple match (e.g., `x86_64-unknown-linux-gnu`).
20    Exact(String),
21    /// Match by OS (and optionally arch) components.
22    OsArch { os: String, arch: Option<String> },
23    /// Restrict to an explicit list of target triples. Used by the
24    /// Determinism Harness and `release --targets=<csv>` to drive
25    /// platform-sharded rebuilds: the build stage retains only those
26    /// configured targets that intersect the supplied list, leaving the
27    /// remaining cross-shard targets to sibling jobs.
28    Targets(Vec<String>),
29}
30
31impl PartialTarget {
32    /// Filter a list of target triples to those matching this partial target.
33    pub fn filter_targets(&self, targets: &[String]) -> Vec<String> {
34        match self {
35            PartialTarget::Exact(t) => targets.iter().filter(|tt| *tt == t).cloned().collect(),
36            PartialTarget::OsArch { os, arch } => targets
37                .iter()
38                .filter(|tt| {
39                    let (t_os, t_arch) = target::map_target(tt);
40                    t_os == *os && arch.as_ref().is_none_or(|a| t_arch == *a)
41                })
42                .cloned()
43                .collect(),
44            PartialTarget::Targets(list) => targets
45                .iter()
46                .filter(|tt| list.iter().any(|wanted| wanted == *tt))
47                .cloned()
48                .collect(),
49        }
50    }
51
52    /// Return the dist subdirectory name for this partial target.
53    /// - `Exact("x86_64-unknown-linux-gnu")` → `"x86_64-unknown-linux-gnu"`
54    /// - `OsArch { os: "linux", arch: None }` → `"linux"`
55    /// - `OsArch { os: "linux", arch: Some("amd64") }` → `"linux_amd64"`
56    /// - `Targets(["x86_64-...", "aarch64-..."])` → `"targets-x86_64-..."` (first triple)
57    ///
58    /// # Design note
59    ///
60    /// A Go-style layout writes split shards to `dist/$GOOS` (or
61    /// `dist/$GOOS_$GOARCH` when `partial.by: target`). Anodizer
62    /// matches that shape for the `OsArch` variant — `OsArch { os:
63    /// "linux", arch: None }` resolves to `"linux"`, identical to the
64    /// `dist/linux` — but the `Exact` variant uses the full Rust target
65    /// triple instead of the Go-style `<goos>_<goarch>` (because the
66    /// triple is the natural granularity for Rust toolchains), and the
67    /// `Targets` variant is anodizer-only (drives the determinism
68    /// harness's sharded matrix, not user-facing CI fan-out).
69    ///
70    /// Practical consequence: split shards produced by anodizer cannot
71    /// be merged by a Go-style consumer and vice versa. Anodizer's CLI does
72    /// not attempt cross-tool interop; the subdir name is purely
73    /// internal to the per-tool merge step.
74    ///
75    /// ```
76    /// use anodizer_core::partial::PartialTarget;
77    ///
78    /// // OsArch matches the `dist/linux` shape exactly.
79    /// assert_eq!(
80    ///     PartialTarget::OsArch { os: "linux".into(), arch: None }.dist_subdir(),
81    ///     "linux",
82    /// );
83    ///
84    /// // Exact uses the full Rust triple (not `linux_amd64`).
85    /// assert_eq!(
86    ///     PartialTarget::Exact("x86_64-unknown-linux-gnu".into()).dist_subdir(),
87    ///     "x86_64-unknown-linux-gnu",
88    /// );
89    /// ```
90    pub fn dist_subdir(&self) -> String {
91        match self {
92            PartialTarget::Exact(t) => t.clone(),
93            PartialTarget::OsArch { os, arch } => {
94                if let Some(a) = arch {
95                    format!("{}_{}", os, a)
96                } else {
97                    os.clone()
98                }
99            }
100            PartialTarget::Targets(list) => {
101                // Deterministic name derived from the first triple. This
102                // is only consulted by `--split`/`--merge` for split-
103                // artifact directory naming; the harness path does not
104                // round-trip through `dist/<subdir>/context.json`.
105                match list.first() {
106                    Some(first) => format!("targets-{}", first),
107                    None => "targets-empty".to_string(),
108                }
109            }
110        }
111    }
112
113    /// Recover the [`PartialTarget`] shape a `dist/` subdirectory name was
114    /// written for — the inverse of [`dist_subdir`](Self::dist_subdir), and
115    /// the only recogniser for that name. Callers that must know whether a
116    /// shard directory stands for one triple or for a whole OS classify it
117    /// here rather than re-deriving the shape from the string.
118    ///
119    /// `dist_subdir` names a `Targets` list after its first triple, so the
120    /// recovered `Targets` holds that triple alone; the other three shapes
121    /// round-trip exactly.
122    ///
123    /// ```
124    /// use anodizer_core::partial::PartialTarget;
125    ///
126    /// for shape in [
127    ///     PartialTarget::Exact("x86_64-unknown-linux-gnu".into()),
128    ///     PartialTarget::OsArch { os: "linux".into(), arch: None },
129    ///     PartialTarget::OsArch { os: "linux".into(), arch: Some("amd64".into()) },
130    ///     PartialTarget::Targets(vec!["x86_64-apple-darwin".into()]),
131    /// ] {
132    ///     assert_eq!(PartialTarget::from_dist_subdir(&shape.dist_subdir()), shape);
133    /// }
134    /// ```
135    pub fn from_dist_subdir(subdir: &str) -> PartialTarget {
136        if let Some(rest) = subdir.strip_prefix("targets-") {
137            return if rest.is_empty() || rest == "empty" {
138                PartialTarget::Targets(Vec::new())
139            } else {
140                PartialTarget::Targets(vec![rest.to_string()])
141            };
142        }
143        // `dist_subdir` spells `OsArch` with Go-style os/arch words, which
144        // never contain a hyphen; every target triple does.
145        if subdir.contains('-') {
146            return PartialTarget::Exact(subdir.to_string());
147        }
148        match subdir.split_once('_') {
149            Some((os, arch)) => PartialTarget::OsArch {
150                os: os.to_string(),
151                arch: Some(arch.to_string()),
152            },
153            None => PartialTarget::OsArch {
154                os: subdir.to_string(),
155                arch: None,
156            },
157        }
158    }
159}
160
161// ---------------------------------------------------------------------------
162// Target resolution — env vars → host detection
163// ---------------------------------------------------------------------------
164
165/// Resolve the partial build target from environment variables and config.
166///
167/// Priority chain:
168/// 1. `TARGET` env var — exact target triple (highest priority)
169/// 2. `ANODIZER_OS`/`ANODIZER_ARCH` (canonical) or `GGOOS`/`GGOARCH` (import
170///    alias; filter-only — does not override the host's `GOOS`/`GOARCH` for hooks)
171/// 3. Host detection via `rustc -vV`, interpreted per `partial.by` config
172pub fn resolve_partial_target(config: &Option<PartialConfig>) -> Result<PartialTarget> {
173    resolve_partial_target_with_env(config, &crate::ProcessEnvSource)
174}
175
176/// Env-injectable form of [`resolve_partial_target`]. Production wires up
177/// `ProcessEnvSource`; tests inject a
178/// [`MapEnvSource`](crate::MapEnvSource) to drive the env-var branches
179/// without mutating the process env.
180pub fn resolve_partial_target_with_env<E: EnvSource + ?Sized>(
181    config: &Option<PartialConfig>,
182    env: &E,
183) -> Result<PartialTarget> {
184    // Priority 1: TARGET env var — exact target triple
185    if let Some(t) = env.var("TARGET")
186        && !t.is_empty()
187    {
188        return Ok(PartialTarget::Exact(t));
189    }
190
191    // Priority 2: ANODIZER_OS/ANODIZER_ARCH, or GGOOS/GGOARCH import alias
192    // compatibility. Canonical vars win when both are set.
193    let os = env
194        .var("ANODIZER_OS")
195        .filter(|s| !s.is_empty())
196        .or_else(|| env.var("GGOOS").filter(|s| !s.is_empty()));
197    if let Some(os) = os {
198        let arch = env
199            .var("ANODIZER_ARCH")
200            .filter(|a| !a.is_empty())
201            .or_else(|| env.var("GGOARCH").filter(|a| !a.is_empty()));
202        return Ok(PartialTarget::OsArch { os, arch });
203    }
204
205    // Priority 3: host detection, interpreted per partial.by
206    let host = detect_host_target()?;
207    let by = config
208        .as_ref()
209        .and_then(|c| c.by.as_deref())
210        .unwrap_or("os");
211
212    match by {
213        "os" => {
214            let (os, _) = target::map_target(&host);
215            Ok(PartialTarget::OsArch { os, arch: None })
216        }
217        "target" => Ok(PartialTarget::Exact(host)),
218        other => anyhow::bail!(
219            "partial.by: unknown value '{}' (expected 'os' or 'target')",
220            other
221        ),
222    }
223}
224
225/// Spawn `rustc -vV` once and return its stdout.
226///
227/// Both [`detect_host_target`] (the `host:` line) and
228/// [`detect_rustc_version`] (the `release:` line) parse the same `rustc -vV`
229/// block, so the spawn is centralized here to avoid invoking rustc twice in a
230/// single build/release run. Returns the raw stdout as a `String`.
231fn run_rustc_vv() -> Result<String> {
232    let mut cmd = std::process::Command::new("rustc");
233    cmd.args(["-vV"]);
234    // `rustc -vV` reads nothing relative to the cwd, but rustc still calls
235    // getcwd() at startup and aborts if the inherited cwd was removed; pin it
236    // to a guaranteed-existing dir so host detection is cwd-independent.
237    cmd.current_dir(crate::path_util::probe_dir());
238    tracing::debug!(args = ?cmd.get_args(), "spawning rustc -vV for host/version detection");
239    let output = cmd.output().context("failed to run `rustc -vV`")?;
240
241    if !output.status.success() {
242        anyhow::bail!(
243            "rustc -vV failed: {}",
244            String::from_utf8_lossy(&output.stderr)
245        );
246    }
247    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
248}
249
250/// Extract the `host:` target triple from a `rustc -vV` output block.
251pub(crate) fn parse_host_from_output(output: &str) -> Option<String> {
252    output
253        .lines()
254        .find_map(|line| line.strip_prefix("host: ").map(|h| h.trim().to_string()))
255}
256
257/// Extract the `release:` version (e.g. `"1.96.0"`) from a `rustc -vV` block.
258pub(crate) fn parse_rustc_version_from_output(output: &str) -> Option<String> {
259    output
260        .lines()
261        .find_map(|line| line.strip_prefix("release: ").map(|v| v.trim().to_string()))
262}
263
264/// Detect the host target triple via `rustc -vV`.
265pub fn detect_host_target() -> Result<String> {
266    let stdout = run_rustc_vv()?;
267    parse_host_from_output(&stdout).context("could not detect host target from `rustc -vV` output")
268}
269
270/// Detect the rustc release version string via `rustc -vV`.
271///
272/// Parses the `release:` line from `rustc -vV` output (e.g. `"1.96.0"`).
273/// Returns `None` gracefully when rustc is unavailable, the command fails,
274/// or the line is absent — callers treat a missing version as an empty string.
275pub fn detect_rustc_version() -> Option<String> {
276    let stdout = run_rustc_vv().ok()?;
277    parse_rustc_version_from_output(&stdout)
278}
279
280/// Resolve the effective host target triple for `--single-target`.
281///
282/// Priority chain (host-target detection so a config originally written for a
283/// Go-style consumer
284/// keeps the same CI escape hatches under anodizer):
285/// 1. `TARGET=<triple>` env var (exact triple, highest priority).
286/// 2. `GGOOS` / `GGOARCH` filter-only aliases combined with the host
287///    triple to synthesize a `<arch>-...-<os>...` shape that
288///    [`find_runtime_target`] can later match against configured targets.
289///    These do NOT bleed into hook subprocesses' `GOOS` / `GOARCH`.
290/// 3. `rustc -vV` host detection.
291///
292/// `GGOOS`/`GGOARCH` are honored on a best-effort basis — without a real
293/// `GOOS` -> rust-triple mapping the synthesized string is only useful
294/// when paired with the alias-table fallback in
295/// [`find_runtime_target`]. When both env vars are absent the resolver
296/// returns the raw `rustc -vV` host triple.
297pub fn resolve_host_target_with_env<E: EnvSource + ?Sized>(env: &E) -> Result<String> {
298    // Priority 1: TARGET env var - exact triple override.
299    if let Some(t) = env.var("TARGET")
300        && !t.trim().is_empty()
301    {
302        return Ok(t);
303    }
304
305    // Priority 2 + 3: detect the host first; if `GGOOS`/`GGOARCH` were
306    // supplied as filter-only overrides, rewrite the OS/arch components
307    // of the host triple so downstream filtering picks up the override.
308    let host = detect_host_target()?;
309    let ggoos = env.var("GGOOS").filter(|s| !s.trim().is_empty());
310    let ggoarch = env.var("GGOARCH").filter(|s| !s.trim().is_empty());
311    if ggoos.is_some() || ggoarch.is_some() {
312        return Ok(synthesize_triple_with_overrides(
313            &host,
314            ggoos.as_deref(),
315            ggoarch.as_deref(),
316        ));
317    }
318    Ok(host)
319}
320
321/// Process-env form of [`resolve_host_target_with_env`].
322pub fn resolve_host_target() -> Result<String> {
323    resolve_host_target_with_env(&crate::ProcessEnvSource)
324}
325
326/// Best-effort host->triple fuzzy matcher for `--single-target`.
327///
328/// Host-target detection: walks
329/// `goos -> {macos, darwin}` and `goarch -> {x86_64, amd64, arm64,
330/// aarch64, 386 -> i686/i586/i386}` alias tables to find a configured
331/// target that matches the runtime even when the user's `targets:`
332/// list spells a semantically-equivalent but lexically-different triple
333/// than `rustc -vV`. Returns the first configured target whose
334/// `(os, arch)` (via [`crate::target::map_target`]) matches the host
335/// after alias normalization, or `None` when nothing matches.
336pub fn find_runtime_target(host: &str, configured: &[String]) -> Option<String> {
337    let (host_os, host_arch) = crate::target::map_target(host);
338    configured
339        .iter()
340        .find(|t| {
341            let (t_os, t_arch) = crate::target::map_target(t);
342            t_os == host_os && t_arch == host_arch
343        })
344        .cloned()
345}
346
347/// Replace the OS / arch first-component of `host_triple` with the
348/// supplied `GGOOS` / `GGOARCH` overrides so the synthesized string
349/// passes through [`find_runtime_target`] correctly.
350///
351/// The mapping accepts both Go-style aliases (`darwin`, `amd64`,
352/// `arm64`, `386`) and their rust-triple spellings (`apple-darwin`,
353/// `x86_64`, `aarch64`, `i686`). Unknown values are passed through
354/// verbatim — best-effort behaviour matching `findRuntime`.
355fn synthesize_triple_with_overrides(
356    host_triple: &str,
357    goos: Option<&str>,
358    goarch: Option<&str>,
359) -> String {
360    // Map alias -> canonical rust component for every recognised spelling.
361    let arch_token = goarch.map(|a| match a {
362        "amd64" | "x86_64" => "x86_64",
363        "arm64" | "aarch64" => "aarch64",
364        "386" | "i686" => "i686",
365        other => other,
366    });
367    let os_token = goos.map(|o| match o {
368        "darwin" | "macos" => "apple-darwin",
369        "linux" => "unknown-linux-gnu",
370        "windows" => "pc-windows-msvc",
371        other => other,
372    });
373
374    // Pull the original components apart.
375    let parts: Vec<&str> = host_triple.split('-').collect();
376    let original_arch = parts.first().copied().unwrap_or("");
377    let original_rest = if parts.len() > 1 {
378        parts[1..].join("-")
379    } else {
380        String::new()
381    };
382
383    let new_arch = arch_token.unwrap_or(original_arch);
384    let new_rest = os_token.map(str::to_string).unwrap_or(original_rest);
385
386    if new_rest.is_empty() {
387        new_arch.to_string()
388    } else {
389        format!("{}-{}", new_arch, new_rest)
390    }
391}
392
393// ---------------------------------------------------------------------------
394// Host-buildable target filtering (--host-targets)
395// ---------------------------------------------------------------------------
396
397/// Why a configured target cannot be built on the current host. Each
398/// variant maps to a cross-compile case anodizer's cargo-zigbuild path
399/// cannot satisfy off the corresponding native host.
400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
401enum HostConstraint {
402    /// Apple (`*-apple-*`, including `*-apple-darwin` and iOS) targets need
403    /// the macOS SDK (Security / CoreFoundation frameworks) only present on
404    /// a real Mac. cargo-zigbuild cannot synthesize it.
405    NeedsAppleHost,
406    /// Windows-MSVC (`*-windows-msvc`) targets need the MSVC SDK / CRT
407    /// headers (e.g. `assert.h`) that cargo-zigbuild does not bundle; only a
408    /// Windows host has them. `*-windows-gnu` is unaffected (zig ships the
409    /// MinGW runtime) and builds from any host.
410    NeedsWindowsHost,
411}
412
413impl HostConstraint {
414    /// Human-readable clause naming the host this constraint requires, used
415    /// to build the loud skip message and the empty-result hard error.
416    fn reason(self) -> &'static str {
417        match self {
418            HostConstraint::NeedsAppleHost => "apple targets require a macOS host",
419            HostConstraint::NeedsWindowsHost => "windows-msvc targets require a Windows host",
420        }
421    }
422}
423
424/// Classify why `triple` is not buildable on `host`, or `None` when the host
425/// can build it.
426///
427/// A target needs a specific host when it is an apple target on a non-apple
428/// host, or a windows-msvc target on a non-windows host. Everything else
429/// (linux gnu/musl, `*-windows-gnu`, ...) is cross-buildable from any host.
430fn target_host_constraint(host: &str, triple: &str) -> Option<HostConstraint> {
431    if crate::target::is_darwin(triple) && !host_is_apple(host) {
432        Some(HostConstraint::NeedsAppleHost)
433    } else if crate::target::is_windows_msvc(triple) && !host_is_windows(host) {
434        Some(HostConstraint::NeedsWindowsHost)
435    } else {
436        None
437    }
438}
439
440/// `true` when the host triple is an Apple/Darwin host (and can therefore
441/// build Apple targets in addition to everything else).
442pub fn host_is_apple(host: &str) -> bool {
443    crate::target::is_darwin(host)
444}
445
446/// `true` when the host triple is a Windows host (and can therefore build
447/// windows-msvc targets in addition to everything else).
448pub fn host_is_windows(host: &str) -> bool {
449    crate::target::is_windows(host)
450}
451
452/// Partition `configured` targets into `(buildable, skipped)` for the
453/// given host triple, per the `--host-targets` rule.
454///
455/// Every configured target is kept EXCEPT those that need a native host the
456/// current host is not:
457/// - apple (`*-apple-*`) targets are skipped off a non-Apple host (they need
458///   the macOS SDK only present on a real Mac), and
459/// - windows-msvc (`*-windows-msvc`) targets are skipped off a non-Windows
460///   host (they need the MSVC SDK / CRT headers cargo-zigbuild does not bundle).
461///
462/// Linux (gnu/musl), `*-windows-gnu`, and all other targets are kept from any
463/// host (cargo-zigbuild cross-links them). Order is preserved within each
464/// partition.
465///
466/// ```
467/// use anodizer_core::partial::host_buildable_targets;
468///
469/// let configured = vec![
470///     "x86_64-unknown-linux-gnu".to_string(),
471///     "x86_64-pc-windows-gnu".to_string(),
472///     "x86_64-pc-windows-msvc".to_string(),
473///     "x86_64-apple-darwin".to_string(),
474/// ];
475/// let (kept, skipped) =
476///     host_buildable_targets("x86_64-unknown-linux-gnu", &configured);
477/// assert_eq!(
478///     kept,
479///     vec!["x86_64-unknown-linux-gnu", "x86_64-pc-windows-gnu"],
480/// );
481/// assert_eq!(
482///     skipped,
483///     vec!["x86_64-pc-windows-msvc", "x86_64-apple-darwin"],
484/// );
485/// ```
486pub fn host_buildable_targets(host: &str, configured: &[String]) -> (Vec<String>, Vec<String>) {
487    let mut kept = Vec::new();
488    let mut skipped = Vec::new();
489    for t in configured {
490        if target_host_constraint(host, t).is_some() {
491            skipped.push(t.clone());
492        } else {
493            kept.push(t.clone());
494        }
495    }
496    (kept, skipped)
497}
498
499/// Render the single loud-log line emitted when `--host-targets` skips
500/// configured targets, naming the host OS, the count, and — grouped by
501/// reason — which triples were skipped and why. Returns `None` when nothing
502/// was skipped.
503///
504/// Example:
505/// `skipped 3 target(s) — not buildable on this linux host (--host-targets):
506/// aarch64-apple-darwin, x86_64-apple-darwin (apple targets require a macOS
507/// host); x86_64-pc-windows-msvc (windows-msvc targets require a Windows host)`
508pub fn host_targets_skip_message(host: &str, skipped: &[String]) -> Option<String> {
509    if skipped.is_empty() {
510        return None;
511    }
512    let (host_os, _) = crate::target::map_target(host);
513    Some(format!(
514        "skipped {} target(s) — not buildable on this {} host (--host-targets): {}",
515        skipped.len(),
516        host_os,
517        host_targets_skip_reasons(host, skipped),
518    ))
519}
520
521/// Group `skipped` triples by their host constraint and render
522/// `<triples> (<reason>)` clauses joined by `; `, preserving the apple →
523/// windows order so the message is deterministic. Each triple is attributed
524/// to the constraint that caused it to be skipped.
525///
526/// Consumed by the loud skip line and by the `--host-targets` empty-result
527/// hard error (where every configured target was skipped), so the error
528/// names the native host each group needs rather than a hardcoded remedy.
529pub fn host_targets_skip_reasons(host: &str, skipped: &[String]) -> String {
530    [
531        HostConstraint::NeedsAppleHost,
532        HostConstraint::NeedsWindowsHost,
533    ]
534    .into_iter()
535    .filter_map(|constraint| {
536        let triples: Vec<&str> = skipped
537            .iter()
538            .filter(|t| target_host_constraint(host, t) == Some(constraint))
539            .map(String::as_str)
540            .collect();
541        if triples.is_empty() {
542            None
543        } else {
544            Some(format!("{} ({})", triples.join(", "), constraint.reason()))
545        }
546    })
547    .collect::<Vec<_>>()
548    .join("; ")
549}
550
551/// Suggest a GitHub Actions runner for a given OS.
552pub fn suggest_runner(os: &str) -> &'static str {
553    match os {
554        "linux" => "ubuntu-latest",
555        "darwin" => "macos-latest",
556        "windows" => "windows-latest",
557        _ => "ubuntu-latest", // cross-compile
558    }
559}
560
561// ---------------------------------------------------------------------------
562// Tests
563// ---------------------------------------------------------------------------
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use crate::config::PartialConfig;
569    use serial_test::serial;
570
571    // -----------------------------------------------------------------------
572    // PartialTarget filtering
573    // -----------------------------------------------------------------------
574
575    #[test]
576    fn test_exact_filter_matches_one() {
577        let target = PartialTarget::Exact("x86_64-unknown-linux-gnu".to_string());
578        let targets = vec![
579            "x86_64-unknown-linux-gnu".to_string(),
580            "aarch64-unknown-linux-gnu".to_string(),
581            "x86_64-apple-darwin".to_string(),
582        ];
583        let filtered = target.filter_targets(&targets);
584        assert_eq!(filtered, vec!["x86_64-unknown-linux-gnu"]);
585    }
586
587    #[test]
588    fn test_exact_filter_no_match() {
589        let target = PartialTarget::Exact("riscv64gc-unknown-linux-gnu".to_string());
590        let targets = vec![
591            "x86_64-unknown-linux-gnu".to_string(),
592            "aarch64-apple-darwin".to_string(),
593        ];
594        let filtered = target.filter_targets(&targets);
595        assert!(filtered.is_empty());
596    }
597
598    #[test]
599    fn test_os_filter_matches_all_linux() {
600        let target = PartialTarget::OsArch {
601            os: "linux".to_string(),
602            arch: None,
603        };
604        let targets = vec![
605            "x86_64-unknown-linux-gnu".to_string(),
606            "aarch64-unknown-linux-gnu".to_string(),
607            "x86_64-apple-darwin".to_string(),
608            "x86_64-pc-windows-msvc".to_string(),
609        ];
610        let filtered = target.filter_targets(&targets);
611        assert_eq!(
612            filtered,
613            vec!["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu",]
614        );
615    }
616
617    #[test]
618    fn test_os_arch_filter() {
619        let target = PartialTarget::OsArch {
620            os: "linux".to_string(),
621            arch: Some("arm64".to_string()),
622        };
623        let targets = vec![
624            "x86_64-unknown-linux-gnu".to_string(),
625            "aarch64-unknown-linux-gnu".to_string(),
626        ];
627        let filtered = target.filter_targets(&targets);
628        assert_eq!(filtered, vec!["aarch64-unknown-linux-gnu"]);
629    }
630
631    #[test]
632    fn test_os_filter_darwin() {
633        let target = PartialTarget::OsArch {
634            os: "darwin".to_string(),
635            arch: None,
636        };
637        let targets = vec![
638            "x86_64-apple-darwin".to_string(),
639            "aarch64-apple-darwin".to_string(),
640            "x86_64-unknown-linux-gnu".to_string(),
641        ];
642        let filtered = target.filter_targets(&targets);
643        assert_eq!(
644            filtered,
645            vec!["x86_64-apple-darwin", "aarch64-apple-darwin"]
646        );
647    }
648
649    #[test]
650    fn test_os_filter_windows() {
651        let target = PartialTarget::OsArch {
652            os: "windows".to_string(),
653            arch: None,
654        };
655        let targets = vec![
656            "x86_64-pc-windows-msvc".to_string(),
657            "aarch64-pc-windows-msvc".to_string(),
658            "x86_64-unknown-linux-gnu".to_string(),
659        ];
660        let filtered = target.filter_targets(&targets);
661        assert_eq!(
662            filtered,
663            vec!["x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"]
664        );
665    }
666
667    // -----------------------------------------------------------------------
668    // Dist subdirectory naming
669    // -----------------------------------------------------------------------
670
671    #[test]
672    fn test_dist_subdir_exact() {
673        let target = PartialTarget::Exact("x86_64-unknown-linux-gnu".to_string());
674        assert_eq!(target.dist_subdir(), "x86_64-unknown-linux-gnu");
675    }
676
677    #[test]
678    fn test_dist_subdir_os_only() {
679        let target = PartialTarget::OsArch {
680            os: "linux".to_string(),
681            arch: None,
682        };
683        assert_eq!(target.dist_subdir(), "linux");
684    }
685
686    #[test]
687    fn test_dist_subdir_os_arch() {
688        let target = PartialTarget::OsArch {
689            os: "linux".to_string(),
690            arch: Some("amd64".to_string()),
691        };
692        assert_eq!(target.dist_subdir(), "linux_amd64");
693    }
694
695    /// `OsArch { os: "linux", arch: None }` must spell `"linux"` —
696    /// byte-for-byte the `dist/linux` shape.
697    /// This is the only `dist_subdir` shape that round-trips between
698    /// the two tools and is therefore worth pinning explicitly.
699    #[test]
700    fn dist_subdir_os_only_matches_goreleaser_layout() {
701        let target = PartialTarget::OsArch {
702            os: "linux".to_string(),
703            arch: None,
704        };
705        assert_eq!(target.dist_subdir(), "linux");
706    }
707
708    /// The `Exact` variant uses the full Rust target triple, which
709    /// diverges from the `dist/$GOOS_$GOARCH` shape. Lock
710    /// the anodizer-specific spelling in so the rustdoc-documented
711    /// divergence is also enforced by a test.
712    #[test]
713    fn dist_subdir_exact_uses_full_rust_triple_not_goos_goarch() {
714        let target = PartialTarget::Exact("x86_64-unknown-linux-gnu".to_string());
715        assert_eq!(target.dist_subdir(), "x86_64-unknown-linux-gnu");
716        assert_ne!(target.dist_subdir(), "linux_amd64");
717    }
718
719    // -----------------------------------------------------------------------
720    // PartialTarget::Targets — explicit triple list (sharded build / harness)
721    // -----------------------------------------------------------------------
722
723    #[test]
724    fn test_targets_filter_matches_intersection() {
725        let target = PartialTarget::Targets(vec![
726            "x86_64-unknown-linux-gnu".to_string(),
727            "aarch64-unknown-linux-gnu".to_string(),
728        ]);
729        let configured = vec![
730            "x86_64-unknown-linux-gnu".to_string(),
731            "aarch64-unknown-linux-gnu".to_string(),
732            "x86_64-apple-darwin".to_string(),
733            "aarch64-apple-darwin".to_string(),
734        ];
735        let filtered = target.filter_targets(&configured);
736        assert_eq!(
737            filtered,
738            vec!["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]
739        );
740    }
741
742    #[test]
743    fn test_targets_filter_drops_non_configured_entries() {
744        // Triples requested but not configured are simply absent from the
745        // result — `filter_targets` is intersection, not union.
746        let target = PartialTarget::Targets(vec![
747            "x86_64-unknown-linux-gnu".to_string(),
748            "x86_64-pc-windows-msvc".to_string(),
749        ]);
750        let configured = vec!["x86_64-unknown-linux-gnu".to_string()];
751        let filtered = target.filter_targets(&configured);
752        assert_eq!(filtered, vec!["x86_64-unknown-linux-gnu"]);
753    }
754
755    #[test]
756    fn test_targets_filter_empty_list_yields_empty() {
757        let target = PartialTarget::Targets(Vec::new());
758        let configured = vec!["x86_64-unknown-linux-gnu".to_string()];
759        assert!(target.filter_targets(&configured).is_empty());
760    }
761
762    #[test]
763    fn test_dist_subdir_targets_uses_first_triple() {
764        let target = PartialTarget::Targets(vec![
765            "x86_64-apple-darwin".to_string(),
766            "aarch64-apple-darwin".to_string(),
767        ]);
768        assert_eq!(target.dist_subdir(), "targets-x86_64-apple-darwin");
769    }
770
771    #[test]
772    fn test_dist_subdir_targets_empty_list_has_stable_name() {
773        let target = PartialTarget::Targets(Vec::new());
774        assert_eq!(target.dist_subdir(), "targets-empty");
775    }
776
777    // -----------------------------------------------------------------------
778    // Host detection
779    // -----------------------------------------------------------------------
780
781    #[test]
782    #[serial(path_env)]
783    fn test_detect_host_target() {
784        // This test runs on whatever machine the test suite runs on.
785        // It should always succeed if rustc is available.
786        let host = detect_host_target().unwrap();
787        assert!(!host.is_empty());
788        // Should contain at least one hyphen (target triple format)
789        assert!(host.contains('-'), "host triple should contain '-': {host}");
790    }
791
792    /// Regression: host detection must survive an inherited working directory
793    /// that has been removed. A peer test swapping the process-global cwd into
794    /// a tempdir and tearing it down used to make `rustc -vV` abort with
795    /// "Could not locate working directory"; `run_rustc_vv` now pins its spawn
796    /// to a guaranteed-existing dir, so detection no longer depends on the cwd.
797    // Unix-only: deleting the directory that is the process cwd is a POSIX
798    // behavior. Windows locks the cwd and refuses to remove it (os error 32),
799    // so the deleted-cwd race this guards against cannot occur there.
800    #[test]
801    #[serial(cwd, path_env)]
802    #[cfg(unix)]
803    fn detect_host_target_survives_deleted_cwd() {
804        let scratch = tempfile::tempdir().unwrap();
805        // RAII: restores the original cwd on drop even if the body panics, so a
806        // peer test reading the process-global cwd never observes a deleted dir.
807        let _cwd = crate::test_helpers::CwdGuard::new(scratch.path()).unwrap();
808        // Remove the directory the process cwd now points at, mimicking a peer
809        // test that dropped its tempdir while this one holds its path as cwd.
810        scratch.close().unwrap();
811
812        let result = detect_host_target();
813
814        let host = result.expect("host detection must succeed despite a deleted cwd");
815        assert!(host.contains('-'), "host triple should contain '-': {host}");
816    }
817
818    // -----------------------------------------------------------------------
819    // resolve_partial_target (without env vars — tests host fallback)
820    // -----------------------------------------------------------------------
821
822    #[test]
823    fn test_resolve_with_os_default() {
824        // Empty env drives the host-fallback branch without mutating process env.
825        let env = crate::MapEnvSource::new();
826
827        let config = None; // defaults to "os"
828        let target = resolve_partial_target_with_env(&config, &env).unwrap();
829
830        // Should be an OsArch with the host's OS
831        match target {
832            PartialTarget::OsArch { os, arch } => {
833                assert!(!os.is_empty());
834                assert!(arch.is_none()); // os mode doesn't set arch
835            }
836            other => panic!("expected OsArch, got: {other:?}"),
837        }
838    }
839
840    #[test]
841    fn test_resolve_with_by_target() {
842        let env = crate::MapEnvSource::new();
843
844        let config = Some(PartialConfig {
845            by: Some("target".to_string()),
846        });
847        let target = resolve_partial_target_with_env(&config, &env).unwrap();
848
849        // Should be an Exact match with the full host triple
850        match target {
851            PartialTarget::Exact(t) => {
852                assert!(t.contains('-'), "should be full triple: {t}");
853            }
854            other => panic!("expected Exact, got: {other:?}"),
855        }
856    }
857
858    #[test]
859    fn test_resolve_invalid_by_value() {
860        let env = crate::MapEnvSource::new();
861
862        let config = Some(PartialConfig {
863            by: Some("invalid".to_string()),
864        });
865        let err = resolve_partial_target_with_env(&config, &env).unwrap_err();
866        assert!(err.to_string().contains("unknown value"), "got: {}", err);
867    }
868
869    #[test]
870    fn test_resolve_by_os_works_and_legacy_goos_rejected() {
871        let env = crate::MapEnvSource::new();
872
873        let ok = resolve_partial_target_with_env(
874            &Some(PartialConfig {
875                by: Some("os".to_string()),
876            }),
877            &env,
878        )
879        .unwrap();
880        assert!(matches!(ok, PartialTarget::OsArch { arch: None, .. }));
881
882        // The Go-named `goos` value was hard-renamed to `os`; the old
883        // spelling must no longer resolve.
884        let err = resolve_partial_target_with_env(
885            &Some(PartialConfig {
886                by: Some("goos".to_string()),
887            }),
888            &env,
889        )
890        .unwrap_err();
891        assert!(err.to_string().contains("unknown value"), "got: {}", err);
892    }
893
894    // -----------------------------------------------------------------------
895    // Runner suggestion
896    // -----------------------------------------------------------------------
897
898    #[test]
899    fn test_suggest_runner() {
900        assert_eq!(suggest_runner("linux"), "ubuntu-latest");
901        assert_eq!(suggest_runner("darwin"), "macos-latest");
902        assert_eq!(suggest_runner("windows"), "windows-latest");
903        assert_eq!(suggest_runner("freebsd"), "ubuntu-latest");
904    }
905
906    // -----------------------------------------------------------------------
907    // resolve_host_target_with_env (--single-target path)
908    // -----------------------------------------------------------------------
909
910    #[test]
911    fn resolve_host_target_honours_target_env_override() {
912        let env = crate::MapEnvSource::new().with("TARGET", "x86_64-unknown-linux-musl");
913        let triple = resolve_host_target_with_env(&env).unwrap();
914        assert_eq!(triple, "x86_64-unknown-linux-musl");
915    }
916
917    #[test]
918    fn resolve_host_target_target_env_wins_over_ggoos() {
919        let env = crate::MapEnvSource::new()
920            .with("TARGET", "aarch64-apple-darwin")
921            .with("GGOOS", "linux")
922            .with("GGOARCH", "amd64");
923        let triple = resolve_host_target_with_env(&env).unwrap();
924        assert_eq!(triple, "aarch64-apple-darwin");
925    }
926
927    #[test]
928    #[serial(path_env)]
929    fn resolve_host_target_blank_target_falls_through() {
930        // A whitespace-only TARGET should be ignored (the
931        // `if t := os.Getenv("TARGET"); t != ""` early-return).
932        let env = crate::MapEnvSource::new().with("TARGET", "   ");
933        let triple = resolve_host_target_with_env(&env).unwrap();
934        assert!(triple.contains('-'), "fell back to rustc -vV: {triple}");
935    }
936
937    #[test]
938    fn ggoos_overrides_host_os_component() {
939        // No TARGET set; GGOOS=darwin should rewrite the host triple's
940        // OS slot to `apple-darwin`.
941        let synthesized = synthesize_triple_with_overrides(
942            "x86_64-unknown-linux-gnu",
943            Some("darwin"),
944            Some("arm64"),
945        );
946        assert_eq!(synthesized, "aarch64-apple-darwin");
947    }
948
949    #[test]
950    fn ggoos_alone_keeps_host_arch() {
951        let synthesized =
952            synthesize_triple_with_overrides("x86_64-unknown-linux-gnu", Some("windows"), None);
953        assert_eq!(synthesized, "x86_64-pc-windows-msvc");
954    }
955
956    #[test]
957    fn ggoarch_alone_keeps_host_os() {
958        let synthesized =
959            synthesize_triple_with_overrides("x86_64-unknown-linux-gnu", None, Some("arm64"));
960        assert_eq!(synthesized, "aarch64-unknown-linux-gnu");
961    }
962
963    // -----------------------------------------------------------------------
964    // find_runtime_target (host-alias fallback for --single-target)
965    // -----------------------------------------------------------------------
966
967    #[test]
968    fn find_runtime_matches_exact() {
969        let configured = vec![
970            "x86_64-unknown-linux-gnu".to_string(),
971            "aarch64-apple-darwin".to_string(),
972        ];
973        let m = find_runtime_target("x86_64-unknown-linux-gnu", &configured);
974        assert_eq!(m.as_deref(), Some("x86_64-unknown-linux-gnu"));
975    }
976
977    #[test]
978    fn find_runtime_matches_by_alias() {
979        // Host says `x86_64-unknown-linux-musl`; configured target uses
980        // `x86_64-unknown-linux-gnu`. Both map to `(linux, amd64)` so
981        // the alias matcher should pair them.
982        let configured = vec!["x86_64-unknown-linux-gnu".to_string()];
983        let m = find_runtime_target("x86_64-unknown-linux-musl", &configured);
984        assert_eq!(m.as_deref(), Some("x86_64-unknown-linux-gnu"));
985    }
986
987    #[test]
988    fn find_runtime_returns_none_when_no_match() {
989        let configured = vec!["aarch64-apple-darwin".to_string()];
990        let m = find_runtime_target("x86_64-unknown-linux-gnu", &configured);
991        assert!(m.is_none());
992    }
993
994    // -----------------------------------------------------------------------
995    // host_buildable_targets (--host-targets)
996    // -----------------------------------------------------------------------
997
998    const LINUX_HOST: &str = "x86_64-unknown-linux-gnu";
999    const MAC_HOST: &str = "aarch64-apple-darwin";
1000    const WINDOWS_HOST: &str = "x86_64-pc-windows-msvc";
1001
1002    /// Full cross-host fixture: 2 linux, 1 windows-gnu, 1 windows-msvc, 2
1003    /// apple. Exercises every classification branch.
1004    fn mixed_targets() -> Vec<String> {
1005        vec![
1006            "x86_64-unknown-linux-gnu".to_string(),
1007            "aarch64-unknown-linux-gnu".to_string(),
1008            "x86_64-pc-windows-gnu".to_string(),
1009            "x86_64-pc-windows-msvc".to_string(),
1010            "x86_64-apple-darwin".to_string(),
1011            "aarch64-apple-darwin".to_string(),
1012        ]
1013    }
1014
1015    #[test]
1016    fn host_buildable_linux_keeps_cross_buildable_skips_apple_and_msvc() {
1017        let (kept, skipped) = host_buildable_targets(LINUX_HOST, &mixed_targets());
1018        assert_eq!(
1019            kept,
1020            vec![
1021                "x86_64-unknown-linux-gnu",
1022                "aarch64-unknown-linux-gnu",
1023                "x86_64-pc-windows-gnu",
1024            ],
1025            "linux + windows-gnu targets are cross-buildable from a linux host"
1026        );
1027        assert_eq!(
1028            skipped,
1029            vec![
1030                "x86_64-pc-windows-msvc",
1031                "x86_64-apple-darwin",
1032                "aarch64-apple-darwin",
1033            ],
1034            "windows-msvc (needs Windows) and apple (needs macOS) are skipped on linux"
1035        );
1036    }
1037
1038    #[test]
1039    fn host_buildable_apple_host_keeps_apple_still_skips_msvc() {
1040        // A macOS host builds apple targets, but windows-msvc still needs a
1041        // Windows host — msvc can't be cross-built even from a Mac.
1042        let (kept, skipped) = host_buildable_targets(MAC_HOST, &mixed_targets());
1043        assert_eq!(
1044            kept,
1045            vec![
1046                "x86_64-unknown-linux-gnu",
1047                "aarch64-unknown-linux-gnu",
1048                "x86_64-pc-windows-gnu",
1049                "x86_64-apple-darwin",
1050                "aarch64-apple-darwin",
1051            ],
1052            "apple host keeps apple + linux + windows-gnu: {kept:?}"
1053        );
1054        assert_eq!(
1055            skipped,
1056            vec!["x86_64-pc-windows-msvc"],
1057            "windows-msvc still needs a Windows host, even from macOS"
1058        );
1059    }
1060
1061    #[test]
1062    fn host_buildable_windows_host_keeps_msvc_skips_apple() {
1063        // A Windows host builds windows-msvc, but apple still needs macOS.
1064        let (kept, skipped) = host_buildable_targets(WINDOWS_HOST, &mixed_targets());
1065        assert_eq!(
1066            kept,
1067            vec![
1068                "x86_64-unknown-linux-gnu",
1069                "aarch64-unknown-linux-gnu",
1070                "x86_64-pc-windows-gnu",
1071                "x86_64-pc-windows-msvc",
1072            ],
1073            "windows host keeps windows-msvc + linux + windows-gnu: {kept:?}"
1074        );
1075        assert_eq!(
1076            skipped,
1077            vec!["x86_64-apple-darwin", "aarch64-apple-darwin"],
1078            "apple targets still need a macOS host, even from Windows"
1079        );
1080    }
1081
1082    #[test]
1083    fn host_buildable_linux_only_config_keeps_all() {
1084        let configured = vec![
1085            "x86_64-unknown-linux-gnu".to_string(),
1086            "x86_64-pc-windows-gnu".to_string(),
1087        ];
1088        let (kept, skipped) = host_buildable_targets(LINUX_HOST, &configured);
1089        assert_eq!(kept, configured);
1090        assert!(skipped.is_empty());
1091    }
1092
1093    #[test]
1094    fn host_buildable_linux_apple_only_config_skips_all() {
1095        let configured = vec![
1096            "x86_64-apple-darwin".to_string(),
1097            "aarch64-apple-darwin".to_string(),
1098        ];
1099        let (kept, skipped) = host_buildable_targets(LINUX_HOST, &configured);
1100        assert!(kept.is_empty(), "a linux host can build no apple targets");
1101        assert_eq!(skipped, configured);
1102    }
1103
1104    #[test]
1105    fn host_buildable_linux_msvc_only_config_skips_all() {
1106        let configured = vec!["x86_64-pc-windows-msvc".to_string()];
1107        let (kept, skipped) = host_buildable_targets(LINUX_HOST, &configured);
1108        assert!(
1109            kept.is_empty(),
1110            "a linux host can build no windows-msvc targets"
1111        );
1112        assert_eq!(skipped, configured);
1113    }
1114
1115    #[test]
1116    fn host_targets_skip_message_names_both_reasons_on_linux() {
1117        // Mixed skip set on a linux host must group both reasons in one line.
1118        let skipped = vec![
1119            "aarch64-apple-darwin".to_string(),
1120            "x86_64-apple-darwin".to_string(),
1121            "x86_64-pc-windows-msvc".to_string(),
1122        ];
1123        let msg = host_targets_skip_message(LINUX_HOST, &skipped).unwrap();
1124        assert!(msg.contains("3 target(s)"), "names the count: {msg}");
1125        assert!(msg.contains("linux host"), "names the host OS: {msg}");
1126        assert!(
1127            msg.contains("apple targets require a macOS host"),
1128            "names the apple reason: {msg}"
1129        );
1130        assert!(
1131            msg.contains("windows-msvc targets require a Windows host"),
1132            "names the msvc reason: {msg}"
1133        );
1134        assert!(msg.contains("aarch64-apple-darwin"), "lists triple: {msg}");
1135        assert!(msg.contains("x86_64-apple-darwin"), "lists triple: {msg}");
1136        assert!(
1137            msg.contains("x86_64-pc-windows-msvc"),
1138            "lists triple: {msg}"
1139        );
1140        // Single grouped line — no per-target spam.
1141        assert_eq!(msg.lines().count(), 1, "stays a single line: {msg}");
1142    }
1143
1144    #[test]
1145    fn host_targets_skip_message_msvc_only_omits_apple_clause() {
1146        // When only msvc is skipped, the message must NOT mention macOS.
1147        let skipped = vec!["x86_64-pc-windows-msvc".to_string()];
1148        let msg = host_targets_skip_message(LINUX_HOST, &skipped).unwrap();
1149        assert!(
1150            msg.contains("windows-msvc targets require a Windows host"),
1151            "names the msvc reason: {msg}"
1152        );
1153        assert!(
1154            !msg.contains("macOS"),
1155            "msvc-only skip must not mention macOS: {msg}"
1156        );
1157    }
1158
1159    #[test]
1160    fn host_targets_skip_message_is_none_when_nothing_skipped() {
1161        assert!(host_targets_skip_message(LINUX_HOST, &[]).is_none());
1162    }
1163
1164    #[test]
1165    fn parse_rustc_version_from_output_parses_release_line() {
1166        let sample = "\
1167rustc 1.96.0 (ac68faa20 2026-05-25)\n\
1168binary: rustc\n\
1169commit-hash: ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96\n\
1170commit-date: 2026-05-25\n\
1171host: x86_64-unknown-linux-gnu\n\
1172release: 1.96.0\n\
1173LLVM version: 22.1.2\n";
1174        assert_eq!(
1175            parse_rustc_version_from_output(sample),
1176            Some("1.96.0".to_string())
1177        );
1178        // The same block must yield the host triple via the sibling parser.
1179        assert_eq!(
1180            parse_host_from_output(sample),
1181            Some("x86_64-unknown-linux-gnu".to_string())
1182        );
1183    }
1184
1185    #[test]
1186    fn parse_rustc_version_from_output_parses_prerelease_line() {
1187        let sample = "\
1188rustc 1.97.0-nightly (abc123 2026-06-01)\n\
1189release: 1.97.0-nightly\n\
1190host: aarch64-apple-darwin\n";
1191        assert_eq!(
1192            parse_rustc_version_from_output(sample),
1193            Some("1.97.0-nightly".to_string())
1194        );
1195    }
1196
1197    #[test]
1198    fn parse_rustc_version_from_output_returns_none_when_line_absent() {
1199        let sample = "binary: rustc\nhost: x86_64-unknown-linux-gnu\n";
1200        assert_eq!(parse_rustc_version_from_output(sample), None);
1201    }
1202
1203    #[test]
1204    #[serial(path_env)]
1205    fn detect_rustc_version_live_returns_nonempty() {
1206        // Requires rustc on PATH — skip gracefully if absent.
1207        if let Some(ver) = detect_rustc_version() {
1208            assert!(!ver.is_empty(), "live rustc version should not be empty");
1209            assert!(
1210                ver.chars().next().is_some_and(|c| c.is_ascii_digit()),
1211                "live rustc version should start with a digit: {ver}"
1212            );
1213        }
1214    }
1215}