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}
23
24impl PartialTarget {
25    /// Filter a list of target triples to those matching this partial target.
26    pub fn filter_targets(&self, targets: &[String]) -> Vec<String> {
27        match self {
28            PartialTarget::Exact(t) => targets.iter().filter(|tt| *tt == t).cloned().collect(),
29            PartialTarget::OsArch { os, arch } => targets
30                .iter()
31                .filter(|tt| {
32                    let (t_os, t_arch) = target::map_target(tt);
33                    t_os == *os && arch.as_ref().is_none_or(|a| t_arch == *a)
34                })
35                .cloned()
36                .collect(),
37        }
38    }
39
40    /// Return the dist subdirectory name for this partial target.
41    /// - `Exact("x86_64-unknown-linux-gnu")` → `"x86_64-unknown-linux-gnu"`
42    /// - `OsArch { os: "linux", arch: None }` → `"linux"`
43    /// - `OsArch { os: "linux", arch: Some("amd64") }` → `"linux_amd64"`
44    pub fn dist_subdir(&self) -> String {
45        match self {
46            PartialTarget::Exact(t) => t.clone(),
47            PartialTarget::OsArch { os, arch } => {
48                if let Some(a) = arch {
49                    format!("{}_{}", os, a)
50                } else {
51                    os.clone()
52                }
53            }
54        }
55    }
56}
57
58// ---------------------------------------------------------------------------
59// Target resolution — env vars → host detection
60// ---------------------------------------------------------------------------
61
62/// Resolve the partial build target from environment variables and config.
63///
64/// Priority chain (matching GoReleaser Pro's approach):
65/// 1. `TARGET` env var — exact target triple (highest priority)
66/// 2. `ANODIZER_OS`/`ANODIZER_ARCH` (canonical) or `GGOOS`/`GGOARCH` (GoReleaser
67///    alias; filter-only — does not override the host's `GOOS`/`GOARCH` for hooks)
68/// 3. Host detection via `rustc -vV`, interpreted per `partial.by` config
69pub fn resolve_partial_target(config: &Option<PartialConfig>) -> Result<PartialTarget> {
70    // Priority 1: TARGET env var — exact target triple
71    if let Ok(t) = std::env::var("TARGET")
72        && !t.is_empty()
73    {
74        return Ok(PartialTarget::Exact(t));
75    }
76
77    // Priority 2: ANODIZER_OS/ANODIZER_ARCH, or GGOOS/GGOARCH alias for GoReleaser
78    // compatibility. Canonical vars win when both are set.
79    let os = std::env::var("ANODIZER_OS")
80        .ok()
81        .filter(|s| !s.is_empty())
82        .or_else(|| std::env::var("GGOOS").ok().filter(|s| !s.is_empty()));
83    if let Some(os) = os {
84        let arch = std::env::var("ANODIZER_ARCH")
85            .ok()
86            .filter(|a| !a.is_empty())
87            .or_else(|| std::env::var("GGOARCH").ok().filter(|a| !a.is_empty()));
88        return Ok(PartialTarget::OsArch { os, arch });
89    }
90
91    // Priority 3: host detection, interpreted per partial.by
92    let host = detect_host_target()?;
93    let by = config
94        .as_ref()
95        .and_then(|c| c.by.as_deref())
96        .unwrap_or("goos");
97
98    match by {
99        "goos" => {
100            let (os, _) = target::map_target(&host);
101            Ok(PartialTarget::OsArch { os, arch: None })
102        }
103        "target" => Ok(PartialTarget::Exact(host)),
104        other => anyhow::bail!(
105            "partial.by: unknown value '{}' (expected 'goos' or 'target')",
106            other
107        ),
108    }
109}
110
111/// Detect the host target triple via `rustc -vV`.
112pub fn detect_host_target() -> Result<String> {
113    let output = std::process::Command::new("rustc")
114        .args(["-vV"])
115        .output()
116        .context("failed to run `rustc -vV` for host target detection")?;
117
118    if !output.status.success() {
119        anyhow::bail!(
120            "rustc -vV failed: {}",
121            String::from_utf8_lossy(&output.stderr)
122        );
123    }
124
125    let stdout = String::from_utf8_lossy(&output.stdout);
126    for line in stdout.lines() {
127        if let Some(host) = line.strip_prefix("host: ") {
128            return Ok(host.trim().to_string());
129        }
130    }
131    anyhow::bail!("could not detect host target from `rustc -vV` output")
132}
133
134/// Suggest a GitHub Actions runner for a given OS.
135pub fn suggest_runner(os: &str) -> &'static str {
136    match os {
137        "linux" => "ubuntu-latest",
138        "darwin" => "macos-latest",
139        "windows" => "windows-latest",
140        _ => "ubuntu-latest", // cross-compile
141    }
142}
143
144// ---------------------------------------------------------------------------
145// Tests
146// ---------------------------------------------------------------------------
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::config::PartialConfig;
152    use serial_test::serial;
153
154    // -----------------------------------------------------------------------
155    // PartialTarget filtering
156    // -----------------------------------------------------------------------
157
158    #[test]
159    fn test_exact_filter_matches_one() {
160        let target = PartialTarget::Exact("x86_64-unknown-linux-gnu".to_string());
161        let targets = vec![
162            "x86_64-unknown-linux-gnu".to_string(),
163            "aarch64-unknown-linux-gnu".to_string(),
164            "x86_64-apple-darwin".to_string(),
165        ];
166        let filtered = target.filter_targets(&targets);
167        assert_eq!(filtered, vec!["x86_64-unknown-linux-gnu"]);
168    }
169
170    #[test]
171    fn test_exact_filter_no_match() {
172        let target = PartialTarget::Exact("riscv64gc-unknown-linux-gnu".to_string());
173        let targets = vec![
174            "x86_64-unknown-linux-gnu".to_string(),
175            "aarch64-apple-darwin".to_string(),
176        ];
177        let filtered = target.filter_targets(&targets);
178        assert!(filtered.is_empty());
179    }
180
181    #[test]
182    fn test_os_filter_matches_all_linux() {
183        let target = PartialTarget::OsArch {
184            os: "linux".to_string(),
185            arch: None,
186        };
187        let targets = vec![
188            "x86_64-unknown-linux-gnu".to_string(),
189            "aarch64-unknown-linux-gnu".to_string(),
190            "x86_64-apple-darwin".to_string(),
191            "x86_64-pc-windows-msvc".to_string(),
192        ];
193        let filtered = target.filter_targets(&targets);
194        assert_eq!(
195            filtered,
196            vec!["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu",]
197        );
198    }
199
200    #[test]
201    fn test_os_arch_filter() {
202        let target = PartialTarget::OsArch {
203            os: "linux".to_string(),
204            arch: Some("arm64".to_string()),
205        };
206        let targets = vec![
207            "x86_64-unknown-linux-gnu".to_string(),
208            "aarch64-unknown-linux-gnu".to_string(),
209        ];
210        let filtered = target.filter_targets(&targets);
211        assert_eq!(filtered, vec!["aarch64-unknown-linux-gnu"]);
212    }
213
214    #[test]
215    fn test_os_filter_darwin() {
216        let target = PartialTarget::OsArch {
217            os: "darwin".to_string(),
218            arch: None,
219        };
220        let targets = vec![
221            "x86_64-apple-darwin".to_string(),
222            "aarch64-apple-darwin".to_string(),
223            "x86_64-unknown-linux-gnu".to_string(),
224        ];
225        let filtered = target.filter_targets(&targets);
226        assert_eq!(
227            filtered,
228            vec!["x86_64-apple-darwin", "aarch64-apple-darwin"]
229        );
230    }
231
232    #[test]
233    fn test_os_filter_windows() {
234        let target = PartialTarget::OsArch {
235            os: "windows".to_string(),
236            arch: None,
237        };
238        let targets = vec![
239            "x86_64-pc-windows-msvc".to_string(),
240            "aarch64-pc-windows-msvc".to_string(),
241            "x86_64-unknown-linux-gnu".to_string(),
242        ];
243        let filtered = target.filter_targets(&targets);
244        assert_eq!(
245            filtered,
246            vec!["x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"]
247        );
248    }
249
250    // -----------------------------------------------------------------------
251    // Dist subdirectory naming
252    // -----------------------------------------------------------------------
253
254    #[test]
255    fn test_dist_subdir_exact() {
256        let target = PartialTarget::Exact("x86_64-unknown-linux-gnu".to_string());
257        assert_eq!(target.dist_subdir(), "x86_64-unknown-linux-gnu");
258    }
259
260    #[test]
261    fn test_dist_subdir_os_only() {
262        let target = PartialTarget::OsArch {
263            os: "linux".to_string(),
264            arch: None,
265        };
266        assert_eq!(target.dist_subdir(), "linux");
267    }
268
269    #[test]
270    fn test_dist_subdir_os_arch() {
271        let target = PartialTarget::OsArch {
272            os: "linux".to_string(),
273            arch: Some("amd64".to_string()),
274        };
275        assert_eq!(target.dist_subdir(), "linux_amd64");
276    }
277
278    // -----------------------------------------------------------------------
279    // Host detection
280    // -----------------------------------------------------------------------
281
282    #[test]
283    fn test_detect_host_target() {
284        // This test runs on whatever machine the test suite runs on.
285        // It should always succeed if rustc is available.
286        let host = detect_host_target().unwrap();
287        assert!(!host.is_empty());
288        // Should contain at least one hyphen (target triple format)
289        assert!(host.contains('-'), "host triple should contain '-': {host}");
290    }
291
292    // -----------------------------------------------------------------------
293    // resolve_partial_target (without env vars — tests host fallback)
294    // -----------------------------------------------------------------------
295
296    #[test]
297    #[serial]
298    fn test_resolve_with_goos_default() {
299        // Clear env vars that might interfere
300        // SAFETY: test-only, no concurrent env var access in these serial tests
301        unsafe {
302            std::env::remove_var("TARGET");
303            std::env::remove_var("ANODIZER_OS");
304            std::env::remove_var("ANODIZER_ARCH");
305        }
306
307        let config = None; // defaults to "goos"
308        let target = resolve_partial_target(&config).unwrap();
309
310        // Should be an OsArch with the host's OS
311        match target {
312            PartialTarget::OsArch { os, arch } => {
313                assert!(!os.is_empty());
314                assert!(arch.is_none()); // goos mode doesn't set arch
315            }
316            other => panic!("expected OsArch, got: {other:?}"),
317        }
318    }
319
320    #[test]
321    #[serial]
322    fn test_resolve_with_by_target() {
323        // SAFETY: test-only, no concurrent env var access in these serial tests
324        unsafe {
325            std::env::remove_var("TARGET");
326            std::env::remove_var("ANODIZER_OS");
327            std::env::remove_var("ANODIZER_ARCH");
328        }
329
330        let config = Some(PartialConfig {
331            by: Some("target".to_string()),
332        });
333        let target = resolve_partial_target(&config).unwrap();
334
335        // Should be an Exact match with the full host triple
336        match target {
337            PartialTarget::Exact(t) => {
338                assert!(t.contains('-'), "should be full triple: {t}");
339            }
340            other => panic!("expected Exact, got: {other:?}"),
341        }
342    }
343
344    #[test]
345    #[serial]
346    fn test_resolve_invalid_by_value() {
347        // SAFETY: test-only, no concurrent env var access in these serial tests
348        unsafe {
349            std::env::remove_var("TARGET");
350            std::env::remove_var("ANODIZER_OS");
351            std::env::remove_var("ANODIZER_ARCH");
352        }
353
354        let config = Some(PartialConfig {
355            by: Some("invalid".to_string()),
356        });
357        let err = resolve_partial_target(&config).unwrap_err();
358        assert!(err.to_string().contains("unknown value"), "got: {}", err);
359    }
360
361    // -----------------------------------------------------------------------
362    // Runner suggestion
363    // -----------------------------------------------------------------------
364
365    #[test]
366    fn test_suggest_runner() {
367        assert_eq!(suggest_runner("linux"), "ubuntu-latest");
368        assert_eq!(suggest_runner("darwin"), "macos-latest");
369        assert_eq!(suggest_runner("windows"), "windows-latest");
370        assert_eq!(suggest_runner("freebsd"), "ubuntu-latest");
371    }
372}