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