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