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/// `host-targets: skipping 3 target(s) not buildable on this linux host:
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        "host-targets: skipping {} target(s) not buildable on this {} host: {}",
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]
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]
755    #[cfg(unix)]
756    fn detect_host_target_survives_deleted_cwd() {
757        let original = std::env::current_dir().unwrap();
758        let scratch = tempfile::tempdir().unwrap();
759        std::env::set_current_dir(scratch.path()).unwrap();
760        // Remove the directory the process cwd now points at, mimicking a peer
761        // test that dropped its tempdir while we hold its path as cwd.
762        scratch.close().unwrap();
763
764        let result = detect_host_target();
765
766        // Restore a valid cwd before asserting so a failure can't cascade.
767        std::env::set_current_dir(&original).unwrap();
768
769        let host = result.expect("host detection must succeed despite a deleted cwd");
770        assert!(host.contains('-'), "host triple should contain '-': {host}");
771    }
772
773    // -----------------------------------------------------------------------
774    // resolve_partial_target (without env vars — tests host fallback)
775    // -----------------------------------------------------------------------
776
777    #[test]
778    #[serial]
779    fn test_resolve_with_os_default() {
780        // Clear env vars that might interfere
781        // SAFETY: test-only, no concurrent env var access in these serial tests
782        unsafe {
783            std::env::remove_var("TARGET");
784            std::env::remove_var("ANODIZER_OS");
785            std::env::remove_var("ANODIZER_ARCH");
786        }
787
788        let config = None; // defaults to "os"
789        let target = resolve_partial_target(&config).unwrap();
790
791        // Should be an OsArch with the host's OS
792        match target {
793            PartialTarget::OsArch { os, arch } => {
794                assert!(!os.is_empty());
795                assert!(arch.is_none()); // os mode doesn't set arch
796            }
797            other => panic!("expected OsArch, got: {other:?}"),
798        }
799    }
800
801    #[test]
802    #[serial]
803    fn test_resolve_with_by_target() {
804        // SAFETY: test-only, no concurrent env var access in these serial tests
805        unsafe {
806            std::env::remove_var("TARGET");
807            std::env::remove_var("ANODIZER_OS");
808            std::env::remove_var("ANODIZER_ARCH");
809        }
810
811        let config = Some(PartialConfig {
812            by: Some("target".to_string()),
813        });
814        let target = resolve_partial_target(&config).unwrap();
815
816        // Should be an Exact match with the full host triple
817        match target {
818            PartialTarget::Exact(t) => {
819                assert!(t.contains('-'), "should be full triple: {t}");
820            }
821            other => panic!("expected Exact, got: {other:?}"),
822        }
823    }
824
825    #[test]
826    #[serial]
827    fn test_resolve_invalid_by_value() {
828        // SAFETY: test-only, no concurrent env var access in these serial tests
829        unsafe {
830            std::env::remove_var("TARGET");
831            std::env::remove_var("ANODIZER_OS");
832            std::env::remove_var("ANODIZER_ARCH");
833        }
834
835        let config = Some(PartialConfig {
836            by: Some("invalid".to_string()),
837        });
838        let err = resolve_partial_target(&config).unwrap_err();
839        assert!(err.to_string().contains("unknown value"), "got: {}", err);
840    }
841
842    #[test]
843    #[serial]
844    fn test_resolve_by_os_works_and_legacy_goos_rejected() {
845        // SAFETY: test-only, no concurrent env var access in these serial tests
846        unsafe {
847            std::env::remove_var("TARGET");
848            std::env::remove_var("ANODIZER_OS");
849            std::env::remove_var("ANODIZER_ARCH");
850        }
851
852        let ok = resolve_partial_target(&Some(PartialConfig {
853            by: Some("os".to_string()),
854        }))
855        .unwrap();
856        assert!(matches!(ok, PartialTarget::OsArch { arch: None, .. }));
857
858        // The Go-named `goos` value was hard-renamed to `os`; the old
859        // spelling must no longer resolve.
860        let err = resolve_partial_target(&Some(PartialConfig {
861            by: Some("goos".to_string()),
862        }))
863        .unwrap_err();
864        assert!(err.to_string().contains("unknown value"), "got: {}", err);
865    }
866
867    // -----------------------------------------------------------------------
868    // Runner suggestion
869    // -----------------------------------------------------------------------
870
871    #[test]
872    fn test_suggest_runner() {
873        assert_eq!(suggest_runner("linux"), "ubuntu-latest");
874        assert_eq!(suggest_runner("darwin"), "macos-latest");
875        assert_eq!(suggest_runner("windows"), "windows-latest");
876        assert_eq!(suggest_runner("freebsd"), "ubuntu-latest");
877    }
878
879    // -----------------------------------------------------------------------
880    // resolve_host_target_with_env (--single-target path)
881    // -----------------------------------------------------------------------
882
883    #[test]
884    fn resolve_host_target_honours_target_env_override() {
885        let env = crate::MapEnvSource::new().with("TARGET", "x86_64-unknown-linux-musl");
886        let triple = resolve_host_target_with_env(&env).unwrap();
887        assert_eq!(triple, "x86_64-unknown-linux-musl");
888    }
889
890    #[test]
891    fn resolve_host_target_target_env_wins_over_ggoos() {
892        let env = crate::MapEnvSource::new()
893            .with("TARGET", "aarch64-apple-darwin")
894            .with("GGOOS", "linux")
895            .with("GGOARCH", "amd64");
896        let triple = resolve_host_target_with_env(&env).unwrap();
897        assert_eq!(triple, "aarch64-apple-darwin");
898    }
899
900    #[test]
901    #[serial]
902    fn resolve_host_target_blank_target_falls_through() {
903        // A whitespace-only TARGET should be ignored (the
904        // `if t := os.Getenv("TARGET"); t != ""` early-return).
905        let env = crate::MapEnvSource::new().with("TARGET", "   ");
906        let triple = resolve_host_target_with_env(&env).unwrap();
907        assert!(triple.contains('-'), "fell back to rustc -vV: {triple}");
908    }
909
910    #[test]
911    fn ggoos_overrides_host_os_component() {
912        // No TARGET set; GGOOS=darwin should rewrite the host triple's
913        // OS slot to `apple-darwin`.
914        let synthesized = synthesize_triple_with_overrides(
915            "x86_64-unknown-linux-gnu",
916            Some("darwin"),
917            Some("arm64"),
918        );
919        assert_eq!(synthesized, "aarch64-apple-darwin");
920    }
921
922    #[test]
923    fn ggoos_alone_keeps_host_arch() {
924        let synthesized =
925            synthesize_triple_with_overrides("x86_64-unknown-linux-gnu", Some("windows"), None);
926        assert_eq!(synthesized, "x86_64-pc-windows-msvc");
927    }
928
929    #[test]
930    fn ggoarch_alone_keeps_host_os() {
931        let synthesized =
932            synthesize_triple_with_overrides("x86_64-unknown-linux-gnu", None, Some("arm64"));
933        assert_eq!(synthesized, "aarch64-unknown-linux-gnu");
934    }
935
936    // -----------------------------------------------------------------------
937    // find_runtime_target (host-alias fallback for --single-target)
938    // -----------------------------------------------------------------------
939
940    #[test]
941    fn find_runtime_matches_exact() {
942        let configured = vec![
943            "x86_64-unknown-linux-gnu".to_string(),
944            "aarch64-apple-darwin".to_string(),
945        ];
946        let m = find_runtime_target("x86_64-unknown-linux-gnu", &configured);
947        assert_eq!(m.as_deref(), Some("x86_64-unknown-linux-gnu"));
948    }
949
950    #[test]
951    fn find_runtime_matches_by_alias() {
952        // Host says `x86_64-unknown-linux-musl`; configured target uses
953        // `x86_64-unknown-linux-gnu`. Both map to `(linux, amd64)` so
954        // the alias matcher should pair them.
955        let configured = vec!["x86_64-unknown-linux-gnu".to_string()];
956        let m = find_runtime_target("x86_64-unknown-linux-musl", &configured);
957        assert_eq!(m.as_deref(), Some("x86_64-unknown-linux-gnu"));
958    }
959
960    #[test]
961    fn find_runtime_returns_none_when_no_match() {
962        let configured = vec!["aarch64-apple-darwin".to_string()];
963        let m = find_runtime_target("x86_64-unknown-linux-gnu", &configured);
964        assert!(m.is_none());
965    }
966
967    // -----------------------------------------------------------------------
968    // host_buildable_targets (--host-targets)
969    // -----------------------------------------------------------------------
970
971    const LINUX_HOST: &str = "x86_64-unknown-linux-gnu";
972    const MAC_HOST: &str = "aarch64-apple-darwin";
973    const WINDOWS_HOST: &str = "x86_64-pc-windows-msvc";
974
975    /// Full cross-host fixture: 2 linux, 1 windows-gnu, 1 windows-msvc, 2
976    /// apple. Exercises every classification branch.
977    fn mixed_targets() -> Vec<String> {
978        vec![
979            "x86_64-unknown-linux-gnu".to_string(),
980            "aarch64-unknown-linux-gnu".to_string(),
981            "x86_64-pc-windows-gnu".to_string(),
982            "x86_64-pc-windows-msvc".to_string(),
983            "x86_64-apple-darwin".to_string(),
984            "aarch64-apple-darwin".to_string(),
985        ]
986    }
987
988    #[test]
989    fn host_buildable_linux_keeps_cross_buildable_skips_apple_and_msvc() {
990        let (kept, skipped) = host_buildable_targets(LINUX_HOST, &mixed_targets());
991        assert_eq!(
992            kept,
993            vec![
994                "x86_64-unknown-linux-gnu",
995                "aarch64-unknown-linux-gnu",
996                "x86_64-pc-windows-gnu",
997            ],
998            "linux + windows-gnu targets are cross-buildable from a linux host"
999        );
1000        assert_eq!(
1001            skipped,
1002            vec![
1003                "x86_64-pc-windows-msvc",
1004                "x86_64-apple-darwin",
1005                "aarch64-apple-darwin",
1006            ],
1007            "windows-msvc (needs Windows) and apple (needs macOS) are skipped on linux"
1008        );
1009    }
1010
1011    #[test]
1012    fn host_buildable_apple_host_keeps_apple_still_skips_msvc() {
1013        // A macOS host builds apple targets, but windows-msvc still needs a
1014        // Windows host — msvc can't be cross-built even from a Mac.
1015        let (kept, skipped) = host_buildable_targets(MAC_HOST, &mixed_targets());
1016        assert_eq!(
1017            kept,
1018            vec![
1019                "x86_64-unknown-linux-gnu",
1020                "aarch64-unknown-linux-gnu",
1021                "x86_64-pc-windows-gnu",
1022                "x86_64-apple-darwin",
1023                "aarch64-apple-darwin",
1024            ],
1025            "apple host keeps apple + linux + windows-gnu: {kept:?}"
1026        );
1027        assert_eq!(
1028            skipped,
1029            vec!["x86_64-pc-windows-msvc"],
1030            "windows-msvc still needs a Windows host, even from macOS"
1031        );
1032    }
1033
1034    #[test]
1035    fn host_buildable_windows_host_keeps_msvc_skips_apple() {
1036        // A Windows host builds windows-msvc, but apple still needs macOS.
1037        let (kept, skipped) = host_buildable_targets(WINDOWS_HOST, &mixed_targets());
1038        assert_eq!(
1039            kept,
1040            vec![
1041                "x86_64-unknown-linux-gnu",
1042                "aarch64-unknown-linux-gnu",
1043                "x86_64-pc-windows-gnu",
1044                "x86_64-pc-windows-msvc",
1045            ],
1046            "windows host keeps windows-msvc + linux + windows-gnu: {kept:?}"
1047        );
1048        assert_eq!(
1049            skipped,
1050            vec!["x86_64-apple-darwin", "aarch64-apple-darwin"],
1051            "apple targets still need a macOS host, even from Windows"
1052        );
1053    }
1054
1055    #[test]
1056    fn host_buildable_linux_only_config_keeps_all() {
1057        let configured = vec![
1058            "x86_64-unknown-linux-gnu".to_string(),
1059            "x86_64-pc-windows-gnu".to_string(),
1060        ];
1061        let (kept, skipped) = host_buildable_targets(LINUX_HOST, &configured);
1062        assert_eq!(kept, configured);
1063        assert!(skipped.is_empty());
1064    }
1065
1066    #[test]
1067    fn host_buildable_linux_apple_only_config_skips_all() {
1068        let configured = vec![
1069            "x86_64-apple-darwin".to_string(),
1070            "aarch64-apple-darwin".to_string(),
1071        ];
1072        let (kept, skipped) = host_buildable_targets(LINUX_HOST, &configured);
1073        assert!(kept.is_empty(), "a linux host can build no apple targets");
1074        assert_eq!(skipped, configured);
1075    }
1076
1077    #[test]
1078    fn host_buildable_linux_msvc_only_config_skips_all() {
1079        let configured = vec!["x86_64-pc-windows-msvc".to_string()];
1080        let (kept, skipped) = host_buildable_targets(LINUX_HOST, &configured);
1081        assert!(
1082            kept.is_empty(),
1083            "a linux host can build no windows-msvc targets"
1084        );
1085        assert_eq!(skipped, configured);
1086    }
1087
1088    #[test]
1089    fn host_targets_skip_message_names_both_reasons_on_linux() {
1090        // Mixed skip set on a linux host must group both reasons in one line.
1091        let skipped = vec![
1092            "aarch64-apple-darwin".to_string(),
1093            "x86_64-apple-darwin".to_string(),
1094            "x86_64-pc-windows-msvc".to_string(),
1095        ];
1096        let msg = host_targets_skip_message(LINUX_HOST, &skipped).unwrap();
1097        assert!(msg.contains("3 target(s)"), "names the count: {msg}");
1098        assert!(msg.contains("linux host"), "names the host OS: {msg}");
1099        assert!(
1100            msg.contains("apple targets require a macOS host"),
1101            "names the apple reason: {msg}"
1102        );
1103        assert!(
1104            msg.contains("windows-msvc targets require a Windows host"),
1105            "names the msvc reason: {msg}"
1106        );
1107        assert!(msg.contains("aarch64-apple-darwin"), "lists triple: {msg}");
1108        assert!(msg.contains("x86_64-apple-darwin"), "lists triple: {msg}");
1109        assert!(
1110            msg.contains("x86_64-pc-windows-msvc"),
1111            "lists triple: {msg}"
1112        );
1113        // Single grouped line — no per-target spam.
1114        assert_eq!(msg.lines().count(), 1, "stays a single line: {msg}");
1115    }
1116
1117    #[test]
1118    fn host_targets_skip_message_msvc_only_omits_apple_clause() {
1119        // When only msvc is skipped, the message must NOT mention macOS.
1120        let skipped = vec!["x86_64-pc-windows-msvc".to_string()];
1121        let msg = host_targets_skip_message(LINUX_HOST, &skipped).unwrap();
1122        assert!(
1123            msg.contains("windows-msvc targets require a Windows host"),
1124            "names the msvc reason: {msg}"
1125        );
1126        assert!(
1127            !msg.contains("macOS"),
1128            "msvc-only skip must not mention macOS: {msg}"
1129        );
1130    }
1131
1132    #[test]
1133    fn host_targets_skip_message_is_none_when_nothing_skipped() {
1134        assert!(host_targets_skip_message(LINUX_HOST, &[]).is_none());
1135    }
1136
1137    #[test]
1138    fn parse_rustc_version_from_output_parses_release_line() {
1139        let sample = "\
1140rustc 1.96.0 (ac68faa20 2026-05-25)\n\
1141binary: rustc\n\
1142commit-hash: ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96\n\
1143commit-date: 2026-05-25\n\
1144host: x86_64-unknown-linux-gnu\n\
1145release: 1.96.0\n\
1146LLVM version: 22.1.2\n";
1147        assert_eq!(
1148            parse_rustc_version_from_output(sample),
1149            Some("1.96.0".to_string())
1150        );
1151        // The same block must yield the host triple via the sibling parser.
1152        assert_eq!(
1153            parse_host_from_output(sample),
1154            Some("x86_64-unknown-linux-gnu".to_string())
1155        );
1156    }
1157
1158    #[test]
1159    fn parse_rustc_version_from_output_parses_prerelease_line() {
1160        let sample = "\
1161rustc 1.97.0-nightly (abc123 2026-06-01)\n\
1162release: 1.97.0-nightly\n\
1163host: aarch64-apple-darwin\n";
1164        assert_eq!(
1165            parse_rustc_version_from_output(sample),
1166            Some("1.97.0-nightly".to_string())
1167        );
1168    }
1169
1170    #[test]
1171    fn parse_rustc_version_from_output_returns_none_when_line_absent() {
1172        let sample = "binary: rustc\nhost: x86_64-unknown-linux-gnu\n";
1173        assert_eq!(parse_rustc_version_from_output(sample), None);
1174    }
1175
1176    #[test]
1177    #[serial]
1178    fn detect_rustc_version_live_returns_nonempty() {
1179        // Requires rustc on PATH — skip gracefully if absent.
1180        if let Some(ver) = detect_rustc_version() {
1181            assert!(!ver.is_empty(), "live rustc version should not be empty");
1182            assert!(
1183                ver.chars().next().is_some_and(|c| c.is_ascii_digit()),
1184                "live rustc version should start with a digit: {ver}"
1185            );
1186        }
1187    }
1188}