1use anyhow::{Context as _, Result};
7
8use crate::EnvSource;
9use crate::config::PartialConfig;
10use crate::target;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum PartialTarget {
19 Exact(String),
21 OsArch { os: String, arch: Option<String> },
23 Targets(Vec<String>),
29}
30
31impl PartialTarget {
32 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 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 match list.first() {
106 Some(first) => format!("targets-{}", first),
107 None => "targets-empty".to_string(),
108 }
109 }
110 }
111 }
112}
113
114pub fn resolve_partial_target(config: &Option<PartialConfig>) -> Result<PartialTarget> {
126 resolve_partial_target_with_env(config, &crate::ProcessEnvSource)
127}
128
129pub fn resolve_partial_target_with_env<E: EnvSource + ?Sized>(
134 config: &Option<PartialConfig>,
135 env: &E,
136) -> Result<PartialTarget> {
137 if let Some(t) = env.var("TARGET")
139 && !t.is_empty()
140 {
141 return Ok(PartialTarget::Exact(t));
142 }
143
144 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 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
178fn 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
199pub(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
206pub(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
213pub 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
219pub fn detect_rustc_version() -> Option<String> {
225 let stdout = run_rustc_vv().ok()?;
226 parse_rustc_version_from_output(&stdout)
227}
228
229pub fn resolve_host_target_with_env<E: EnvSource + ?Sized>(env: &E) -> Result<String> {
247 if let Some(t) = env.var("TARGET")
249 && !t.trim().is_empty()
250 {
251 return Ok(t);
252 }
253
254 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
270pub fn resolve_host_target() -> Result<String> {
272 resolve_host_target_with_env(&crate::ProcessEnvSource)
273}
274
275pub 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
296fn synthesize_triple_with_overrides(
305 host_triple: &str,
306 goos: Option<&str>,
307 goarch: Option<&str>,
308) -> String {
309 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
350enum HostConstraint {
351 NeedsAppleHost,
355 NeedsWindowsHost,
360}
361
362impl HostConstraint {
363 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
373fn 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
389pub fn host_is_apple(host: &str) -> bool {
392 crate::target::is_darwin(host)
393}
394
395pub fn host_is_windows(host: &str) -> bool {
398 crate::target::is_windows(host)
399}
400
401pub 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
448pub 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
470pub 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
500pub 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", }
508}
509
510#[cfg(test)]
515mod tests {
516 use super::*;
517 use crate::config::PartialConfig;
518 use serial_test::serial;
519
520 #[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 #[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 #[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 #[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 #[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 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 #[test]
731 #[serial]
732 fn test_detect_host_target() {
733 let host = detect_host_target().unwrap();
736 assert!(!host.is_empty());
737 assert!(host.contains('-'), "host triple should contain '-': {host}");
739 }
740
741 #[test]
746 #[serial]
747 fn test_resolve_with_os_default() {
748 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; let target = resolve_partial_target(&config).unwrap();
758
759 match target {
761 PartialTarget::OsArch { os, arch } => {
762 assert!(!os.is_empty());
763 assert!(arch.is_none()); }
765 other => panic!("expected OsArch, got: {other:?}"),
766 }
767 }
768
769 #[test]
770 #[serial]
771 fn test_resolve_with_by_target() {
772 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 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 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 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 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 #[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 #[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 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 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 #[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 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 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 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 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 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 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 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 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 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 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}