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