Skip to main content

anodizer_core/
partial.rs

1//! Partial build target resolution for split/merge CI fan-out.
2//!
3//! Equivalent to GoReleaser Pro's `partial.Pipe` — 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    pub fn dist_subdir(&self) -> String {
58        match self {
59            PartialTarget::Exact(t) => t.clone(),
60            PartialTarget::OsArch { os, arch } => {
61                if let Some(a) = arch {
62                    format!("{}_{}", os, a)
63                } else {
64                    os.clone()
65                }
66            }
67            PartialTarget::Targets(list) => {
68                // Deterministic name derived from the first triple. This
69                // is only consulted by `--split`/`--merge` for split-
70                // artifact directory naming; the harness path does not
71                // round-trip through `dist/<subdir>/context.json`.
72                match list.first() {
73                    Some(first) => format!("targets-{}", first),
74                    None => "targets-empty".to_string(),
75                }
76            }
77        }
78    }
79}
80
81// ---------------------------------------------------------------------------
82// Target resolution — env vars → host detection
83// ---------------------------------------------------------------------------
84
85/// Resolve the partial build target from environment variables and config.
86///
87/// Priority chain (matching GoReleaser Pro's approach):
88/// 1. `TARGET` env var — exact target triple (highest priority)
89/// 2. `ANODIZER_OS`/`ANODIZER_ARCH` (canonical) or `GGOOS`/`GGOARCH` (GoReleaser
90///    alias; filter-only — does not override the host's `GOOS`/`GOARCH` for hooks)
91/// 3. Host detection via `rustc -vV`, interpreted per `partial.by` config
92pub fn resolve_partial_target(config: &Option<PartialConfig>) -> Result<PartialTarget> {
93    resolve_partial_target_with_env(config, &crate::ProcessEnvSource)
94}
95
96/// Env-injectable form of [`resolve_partial_target`]. Production wires up
97/// [`ProcessEnvSource`]; tests inject a
98/// [`MapEnvSource`](crate::MapEnvSource) to drive the env-var branches
99/// without mutating the process env.
100pub fn resolve_partial_target_with_env<E: EnvSource + ?Sized>(
101    config: &Option<PartialConfig>,
102    env: &E,
103) -> Result<PartialTarget> {
104    // Priority 1: TARGET env var — exact target triple
105    if let Some(t) = env.var("TARGET")
106        && !t.is_empty()
107    {
108        return Ok(PartialTarget::Exact(t));
109    }
110
111    // Priority 2: ANODIZER_OS/ANODIZER_ARCH, or GGOOS/GGOARCH alias for GoReleaser
112    // compatibility. Canonical vars win when both are set.
113    let os = env
114        .var("ANODIZER_OS")
115        .filter(|s| !s.is_empty())
116        .or_else(|| env.var("GGOOS").filter(|s| !s.is_empty()));
117    if let Some(os) = os {
118        let arch = env
119            .var("ANODIZER_ARCH")
120            .filter(|a| !a.is_empty())
121            .or_else(|| env.var("GGOARCH").filter(|a| !a.is_empty()));
122        return Ok(PartialTarget::OsArch { os, arch });
123    }
124
125    // Priority 3: host detection, interpreted per partial.by
126    let host = detect_host_target()?;
127    let by = config
128        .as_ref()
129        .and_then(|c| c.by.as_deref())
130        .unwrap_or("goos");
131
132    match by {
133        "goos" => {
134            let (os, _) = target::map_target(&host);
135            Ok(PartialTarget::OsArch { os, arch: None })
136        }
137        "target" => Ok(PartialTarget::Exact(host)),
138        other => anyhow::bail!(
139            "partial.by: unknown value '{}' (expected 'goos' or 'target')",
140            other
141        ),
142    }
143}
144
145/// Detect the host target triple via `rustc -vV`.
146pub fn detect_host_target() -> Result<String> {
147    let mut cmd = std::process::Command::new("rustc");
148    cmd.args(["-vV"]);
149    tracing::debug!(args = ?cmd.get_args(), "spawning rustc for host target detection");
150    let output = cmd
151        .output()
152        .context("failed to run `rustc -vV` for host target detection")?;
153
154    if !output.status.success() {
155        anyhow::bail!(
156            "rustc -vV failed: {}",
157            String::from_utf8_lossy(&output.stderr)
158        );
159    }
160
161    let stdout = String::from_utf8_lossy(&output.stdout);
162    for line in stdout.lines() {
163        if let Some(host) = line.strip_prefix("host: ") {
164            return Ok(host.trim().to_string());
165        }
166    }
167    anyhow::bail!("could not detect host target from `rustc -vV` output")
168}
169
170/// Suggest a GitHub Actions runner for a given OS.
171pub fn suggest_runner(os: &str) -> &'static str {
172    match os {
173        "linux" => "ubuntu-latest",
174        "darwin" => "macos-latest",
175        "windows" => "windows-latest",
176        _ => "ubuntu-latest", // cross-compile
177    }
178}
179
180// ---------------------------------------------------------------------------
181// Tests
182// ---------------------------------------------------------------------------
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::config::PartialConfig;
188    use serial_test::serial;
189
190    // -----------------------------------------------------------------------
191    // PartialTarget filtering
192    // -----------------------------------------------------------------------
193
194    #[test]
195    fn test_exact_filter_matches_one() {
196        let target = PartialTarget::Exact("x86_64-unknown-linux-gnu".to_string());
197        let targets = vec![
198            "x86_64-unknown-linux-gnu".to_string(),
199            "aarch64-unknown-linux-gnu".to_string(),
200            "x86_64-apple-darwin".to_string(),
201        ];
202        let filtered = target.filter_targets(&targets);
203        assert_eq!(filtered, vec!["x86_64-unknown-linux-gnu"]);
204    }
205
206    #[test]
207    fn test_exact_filter_no_match() {
208        let target = PartialTarget::Exact("riscv64gc-unknown-linux-gnu".to_string());
209        let targets = vec![
210            "x86_64-unknown-linux-gnu".to_string(),
211            "aarch64-apple-darwin".to_string(),
212        ];
213        let filtered = target.filter_targets(&targets);
214        assert!(filtered.is_empty());
215    }
216
217    #[test]
218    fn test_os_filter_matches_all_linux() {
219        let target = PartialTarget::OsArch {
220            os: "linux".to_string(),
221            arch: None,
222        };
223        let targets = vec![
224            "x86_64-unknown-linux-gnu".to_string(),
225            "aarch64-unknown-linux-gnu".to_string(),
226            "x86_64-apple-darwin".to_string(),
227            "x86_64-pc-windows-msvc".to_string(),
228        ];
229        let filtered = target.filter_targets(&targets);
230        assert_eq!(
231            filtered,
232            vec!["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu",]
233        );
234    }
235
236    #[test]
237    fn test_os_arch_filter() {
238        let target = PartialTarget::OsArch {
239            os: "linux".to_string(),
240            arch: Some("arm64".to_string()),
241        };
242        let targets = vec![
243            "x86_64-unknown-linux-gnu".to_string(),
244            "aarch64-unknown-linux-gnu".to_string(),
245        ];
246        let filtered = target.filter_targets(&targets);
247        assert_eq!(filtered, vec!["aarch64-unknown-linux-gnu"]);
248    }
249
250    #[test]
251    fn test_os_filter_darwin() {
252        let target = PartialTarget::OsArch {
253            os: "darwin".to_string(),
254            arch: None,
255        };
256        let targets = vec![
257            "x86_64-apple-darwin".to_string(),
258            "aarch64-apple-darwin".to_string(),
259            "x86_64-unknown-linux-gnu".to_string(),
260        ];
261        let filtered = target.filter_targets(&targets);
262        assert_eq!(
263            filtered,
264            vec!["x86_64-apple-darwin", "aarch64-apple-darwin"]
265        );
266    }
267
268    #[test]
269    fn test_os_filter_windows() {
270        let target = PartialTarget::OsArch {
271            os: "windows".to_string(),
272            arch: None,
273        };
274        let targets = vec![
275            "x86_64-pc-windows-msvc".to_string(),
276            "aarch64-pc-windows-msvc".to_string(),
277            "x86_64-unknown-linux-gnu".to_string(),
278        ];
279        let filtered = target.filter_targets(&targets);
280        assert_eq!(
281            filtered,
282            vec!["x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"]
283        );
284    }
285
286    // -----------------------------------------------------------------------
287    // Dist subdirectory naming
288    // -----------------------------------------------------------------------
289
290    #[test]
291    fn test_dist_subdir_exact() {
292        let target = PartialTarget::Exact("x86_64-unknown-linux-gnu".to_string());
293        assert_eq!(target.dist_subdir(), "x86_64-unknown-linux-gnu");
294    }
295
296    #[test]
297    fn test_dist_subdir_os_only() {
298        let target = PartialTarget::OsArch {
299            os: "linux".to_string(),
300            arch: None,
301        };
302        assert_eq!(target.dist_subdir(), "linux");
303    }
304
305    #[test]
306    fn test_dist_subdir_os_arch() {
307        let target = PartialTarget::OsArch {
308            os: "linux".to_string(),
309            arch: Some("amd64".to_string()),
310        };
311        assert_eq!(target.dist_subdir(), "linux_amd64");
312    }
313
314    // -----------------------------------------------------------------------
315    // PartialTarget::Targets — explicit triple list (sharded build / harness)
316    // -----------------------------------------------------------------------
317
318    #[test]
319    fn test_targets_filter_matches_intersection() {
320        let target = PartialTarget::Targets(vec![
321            "x86_64-unknown-linux-gnu".to_string(),
322            "aarch64-unknown-linux-gnu".to_string(),
323        ]);
324        let configured = vec![
325            "x86_64-unknown-linux-gnu".to_string(),
326            "aarch64-unknown-linux-gnu".to_string(),
327            "x86_64-apple-darwin".to_string(),
328            "aarch64-apple-darwin".to_string(),
329        ];
330        let filtered = target.filter_targets(&configured);
331        assert_eq!(
332            filtered,
333            vec!["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]
334        );
335    }
336
337    #[test]
338    fn test_targets_filter_drops_non_configured_entries() {
339        // Triples requested but not configured are simply absent from the
340        // result — `filter_targets` is intersection, not union.
341        let target = PartialTarget::Targets(vec![
342            "x86_64-unknown-linux-gnu".to_string(),
343            "x86_64-pc-windows-msvc".to_string(),
344        ]);
345        let configured = vec!["x86_64-unknown-linux-gnu".to_string()];
346        let filtered = target.filter_targets(&configured);
347        assert_eq!(filtered, vec!["x86_64-unknown-linux-gnu"]);
348    }
349
350    #[test]
351    fn test_targets_filter_empty_list_yields_empty() {
352        let target = PartialTarget::Targets(Vec::new());
353        let configured = vec!["x86_64-unknown-linux-gnu".to_string()];
354        assert!(target.filter_targets(&configured).is_empty());
355    }
356
357    #[test]
358    fn test_dist_subdir_targets_uses_first_triple() {
359        let target = PartialTarget::Targets(vec![
360            "x86_64-apple-darwin".to_string(),
361            "aarch64-apple-darwin".to_string(),
362        ]);
363        assert_eq!(target.dist_subdir(), "targets-x86_64-apple-darwin");
364    }
365
366    #[test]
367    fn test_dist_subdir_targets_empty_list_has_stable_name() {
368        let target = PartialTarget::Targets(Vec::new());
369        assert_eq!(target.dist_subdir(), "targets-empty");
370    }
371
372    // -----------------------------------------------------------------------
373    // Host detection
374    // -----------------------------------------------------------------------
375
376    #[test]
377    fn test_detect_host_target() {
378        // This test runs on whatever machine the test suite runs on.
379        // It should always succeed if rustc is available.
380        let host = detect_host_target().unwrap();
381        assert!(!host.is_empty());
382        // Should contain at least one hyphen (target triple format)
383        assert!(host.contains('-'), "host triple should contain '-': {host}");
384    }
385
386    // -----------------------------------------------------------------------
387    // resolve_partial_target (without env vars — tests host fallback)
388    // -----------------------------------------------------------------------
389
390    #[test]
391    #[serial]
392    fn test_resolve_with_goos_default() {
393        // Clear env vars that might interfere
394        // SAFETY: test-only, no concurrent env var access in these serial tests
395        unsafe {
396            std::env::remove_var("TARGET");
397            std::env::remove_var("ANODIZER_OS");
398            std::env::remove_var("ANODIZER_ARCH");
399        }
400
401        let config = None; // defaults to "goos"
402        let target = resolve_partial_target(&config).unwrap();
403
404        // Should be an OsArch with the host's OS
405        match target {
406            PartialTarget::OsArch { os, arch } => {
407                assert!(!os.is_empty());
408                assert!(arch.is_none()); // goos mode doesn't set arch
409            }
410            other => panic!("expected OsArch, got: {other:?}"),
411        }
412    }
413
414    #[test]
415    #[serial]
416    fn test_resolve_with_by_target() {
417        // SAFETY: test-only, no concurrent env var access in these serial tests
418        unsafe {
419            std::env::remove_var("TARGET");
420            std::env::remove_var("ANODIZER_OS");
421            std::env::remove_var("ANODIZER_ARCH");
422        }
423
424        let config = Some(PartialConfig {
425            by: Some("target".to_string()),
426        });
427        let target = resolve_partial_target(&config).unwrap();
428
429        // Should be an Exact match with the full host triple
430        match target {
431            PartialTarget::Exact(t) => {
432                assert!(t.contains('-'), "should be full triple: {t}");
433            }
434            other => panic!("expected Exact, got: {other:?}"),
435        }
436    }
437
438    #[test]
439    #[serial]
440    fn test_resolve_invalid_by_value() {
441        // SAFETY: test-only, no concurrent env var access in these serial tests
442        unsafe {
443            std::env::remove_var("TARGET");
444            std::env::remove_var("ANODIZER_OS");
445            std::env::remove_var("ANODIZER_ARCH");
446        }
447
448        let config = Some(PartialConfig {
449            by: Some("invalid".to_string()),
450        });
451        let err = resolve_partial_target(&config).unwrap_err();
452        assert!(err.to_string().contains("unknown value"), "got: {}", err);
453    }
454
455    // -----------------------------------------------------------------------
456    // Runner suggestion
457    // -----------------------------------------------------------------------
458
459    #[test]
460    fn test_suggest_runner() {
461        assert_eq!(suggest_runner("linux"), "ubuntu-latest");
462        assert_eq!(suggest_runner("darwin"), "macos-latest");
463        assert_eq!(suggest_runner("windows"), "windows-latest");
464        assert_eq!(suggest_runner("freebsd"), "ubuntu-latest");
465    }
466}