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 cmd.current_dir(crate::path_util::probe_dir());
191 tracing::debug!(args = ?cmd.get_args(), "spawning rustc -vV for host/version detection");
192 let output = cmd.output().context("failed to run `rustc -vV`")?;
193
194 if !output.status.success() {
195 anyhow::bail!(
196 "rustc -vV failed: {}",
197 String::from_utf8_lossy(&output.stderr)
198 );
199 }
200 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
201}
202
203pub(crate) fn parse_host_from_output(output: &str) -> Option<String> {
205 output
206 .lines()
207 .find_map(|line| line.strip_prefix("host: ").map(|h| h.trim().to_string()))
208}
209
210pub(crate) fn parse_rustc_version_from_output(output: &str) -> Option<String> {
212 output
213 .lines()
214 .find_map(|line| line.strip_prefix("release: ").map(|v| v.trim().to_string()))
215}
216
217pub fn detect_host_target() -> Result<String> {
219 let stdout = run_rustc_vv()?;
220 parse_host_from_output(&stdout).context("could not detect host target from `rustc -vV` output")
221}
222
223pub fn detect_rustc_version() -> Option<String> {
229 let stdout = run_rustc_vv().ok()?;
230 parse_rustc_version_from_output(&stdout)
231}
232
233pub fn resolve_host_target_with_env<E: EnvSource + ?Sized>(env: &E) -> Result<String> {
251 if let Some(t) = env.var("TARGET")
253 && !t.trim().is_empty()
254 {
255 return Ok(t);
256 }
257
258 let host = detect_host_target()?;
262 let ggoos = env.var("GGOOS").filter(|s| !s.trim().is_empty());
263 let ggoarch = env.var("GGOARCH").filter(|s| !s.trim().is_empty());
264 if ggoos.is_some() || ggoarch.is_some() {
265 return Ok(synthesize_triple_with_overrides(
266 &host,
267 ggoos.as_deref(),
268 ggoarch.as_deref(),
269 ));
270 }
271 Ok(host)
272}
273
274pub fn resolve_host_target() -> Result<String> {
276 resolve_host_target_with_env(&crate::ProcessEnvSource)
277}
278
279pub fn find_runtime_target(host: &str, configured: &[String]) -> Option<String> {
290 let (host_os, host_arch) = crate::target::map_target(host);
291 configured
292 .iter()
293 .find(|t| {
294 let (t_os, t_arch) = crate::target::map_target(t);
295 t_os == host_os && t_arch == host_arch
296 })
297 .cloned()
298}
299
300fn synthesize_triple_with_overrides(
309 host_triple: &str,
310 goos: Option<&str>,
311 goarch: Option<&str>,
312) -> String {
313 let arch_token = goarch.map(|a| match a {
315 "amd64" | "x86_64" => "x86_64",
316 "arm64" | "aarch64" => "aarch64",
317 "386" | "i686" => "i686",
318 other => other,
319 });
320 let os_token = goos.map(|o| match o {
321 "darwin" | "macos" => "apple-darwin",
322 "linux" => "unknown-linux-gnu",
323 "windows" => "pc-windows-msvc",
324 other => other,
325 });
326
327 let parts: Vec<&str> = host_triple.split('-').collect();
329 let original_arch = parts.first().copied().unwrap_or("");
330 let original_rest = if parts.len() > 1 {
331 parts[1..].join("-")
332 } else {
333 String::new()
334 };
335
336 let new_arch = arch_token.unwrap_or(original_arch);
337 let new_rest = os_token.map(str::to_string).unwrap_or(original_rest);
338
339 if new_rest.is_empty() {
340 new_arch.to_string()
341 } else {
342 format!("{}-{}", new_arch, new_rest)
343 }
344}
345
346#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354enum HostConstraint {
355 NeedsAppleHost,
359 NeedsWindowsHost,
364}
365
366impl HostConstraint {
367 fn reason(self) -> &'static str {
370 match self {
371 HostConstraint::NeedsAppleHost => "apple targets require a macOS host",
372 HostConstraint::NeedsWindowsHost => "windows-msvc targets require a Windows host",
373 }
374 }
375}
376
377fn target_host_constraint(host: &str, triple: &str) -> Option<HostConstraint> {
384 if crate::target::is_darwin(triple) && !host_is_apple(host) {
385 Some(HostConstraint::NeedsAppleHost)
386 } else if crate::target::is_windows_msvc(triple) && !host_is_windows(host) {
387 Some(HostConstraint::NeedsWindowsHost)
388 } else {
389 None
390 }
391}
392
393pub fn host_is_apple(host: &str) -> bool {
396 crate::target::is_darwin(host)
397}
398
399pub fn host_is_windows(host: &str) -> bool {
402 crate::target::is_windows(host)
403}
404
405pub fn host_buildable_targets(host: &str, configured: &[String]) -> (Vec<String>, Vec<String>) {
440 let mut kept = Vec::new();
441 let mut skipped = Vec::new();
442 for t in configured {
443 if target_host_constraint(host, t).is_some() {
444 skipped.push(t.clone());
445 } else {
446 kept.push(t.clone());
447 }
448 }
449 (kept, skipped)
450}
451
452pub fn host_targets_skip_message(host: &str, skipped: &[String]) -> Option<String> {
462 if skipped.is_empty() {
463 return None;
464 }
465 let (host_os, _) = crate::target::map_target(host);
466 Some(format!(
467 "skipped {} target(s) — not buildable on this {} host (--host-targets): {}",
468 skipped.len(),
469 host_os,
470 host_targets_skip_reasons(host, skipped),
471 ))
472}
473
474pub fn host_targets_skip_reasons(host: &str, skipped: &[String]) -> String {
483 [
484 HostConstraint::NeedsAppleHost,
485 HostConstraint::NeedsWindowsHost,
486 ]
487 .into_iter()
488 .filter_map(|constraint| {
489 let triples: Vec<&str> = skipped
490 .iter()
491 .filter(|t| target_host_constraint(host, t) == Some(constraint))
492 .map(String::as_str)
493 .collect();
494 if triples.is_empty() {
495 None
496 } else {
497 Some(format!("{} ({})", triples.join(", "), constraint.reason()))
498 }
499 })
500 .collect::<Vec<_>>()
501 .join("; ")
502}
503
504pub fn suggest_runner(os: &str) -> &'static str {
506 match os {
507 "linux" => "ubuntu-latest",
508 "darwin" => "macos-latest",
509 "windows" => "windows-latest",
510 _ => "ubuntu-latest", }
512}
513
514#[cfg(test)]
519mod tests {
520 use super::*;
521 use crate::config::PartialConfig;
522 use serial_test::serial;
523
524 #[test]
529 fn test_exact_filter_matches_one() {
530 let target = PartialTarget::Exact("x86_64-unknown-linux-gnu".to_string());
531 let targets = vec![
532 "x86_64-unknown-linux-gnu".to_string(),
533 "aarch64-unknown-linux-gnu".to_string(),
534 "x86_64-apple-darwin".to_string(),
535 ];
536 let filtered = target.filter_targets(&targets);
537 assert_eq!(filtered, vec!["x86_64-unknown-linux-gnu"]);
538 }
539
540 #[test]
541 fn test_exact_filter_no_match() {
542 let target = PartialTarget::Exact("riscv64gc-unknown-linux-gnu".to_string());
543 let targets = vec![
544 "x86_64-unknown-linux-gnu".to_string(),
545 "aarch64-apple-darwin".to_string(),
546 ];
547 let filtered = target.filter_targets(&targets);
548 assert!(filtered.is_empty());
549 }
550
551 #[test]
552 fn test_os_filter_matches_all_linux() {
553 let target = PartialTarget::OsArch {
554 os: "linux".to_string(),
555 arch: None,
556 };
557 let targets = vec![
558 "x86_64-unknown-linux-gnu".to_string(),
559 "aarch64-unknown-linux-gnu".to_string(),
560 "x86_64-apple-darwin".to_string(),
561 "x86_64-pc-windows-msvc".to_string(),
562 ];
563 let filtered = target.filter_targets(&targets);
564 assert_eq!(
565 filtered,
566 vec!["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu",]
567 );
568 }
569
570 #[test]
571 fn test_os_arch_filter() {
572 let target = PartialTarget::OsArch {
573 os: "linux".to_string(),
574 arch: Some("arm64".to_string()),
575 };
576 let targets = vec![
577 "x86_64-unknown-linux-gnu".to_string(),
578 "aarch64-unknown-linux-gnu".to_string(),
579 ];
580 let filtered = target.filter_targets(&targets);
581 assert_eq!(filtered, vec!["aarch64-unknown-linux-gnu"]);
582 }
583
584 #[test]
585 fn test_os_filter_darwin() {
586 let target = PartialTarget::OsArch {
587 os: "darwin".to_string(),
588 arch: None,
589 };
590 let targets = vec![
591 "x86_64-apple-darwin".to_string(),
592 "aarch64-apple-darwin".to_string(),
593 "x86_64-unknown-linux-gnu".to_string(),
594 ];
595 let filtered = target.filter_targets(&targets);
596 assert_eq!(
597 filtered,
598 vec!["x86_64-apple-darwin", "aarch64-apple-darwin"]
599 );
600 }
601
602 #[test]
603 fn test_os_filter_windows() {
604 let target = PartialTarget::OsArch {
605 os: "windows".to_string(),
606 arch: None,
607 };
608 let targets = vec![
609 "x86_64-pc-windows-msvc".to_string(),
610 "aarch64-pc-windows-msvc".to_string(),
611 "x86_64-unknown-linux-gnu".to_string(),
612 ];
613 let filtered = target.filter_targets(&targets);
614 assert_eq!(
615 filtered,
616 vec!["x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"]
617 );
618 }
619
620 #[test]
625 fn test_dist_subdir_exact() {
626 let target = PartialTarget::Exact("x86_64-unknown-linux-gnu".to_string());
627 assert_eq!(target.dist_subdir(), "x86_64-unknown-linux-gnu");
628 }
629
630 #[test]
631 fn test_dist_subdir_os_only() {
632 let target = PartialTarget::OsArch {
633 os: "linux".to_string(),
634 arch: None,
635 };
636 assert_eq!(target.dist_subdir(), "linux");
637 }
638
639 #[test]
640 fn test_dist_subdir_os_arch() {
641 let target = PartialTarget::OsArch {
642 os: "linux".to_string(),
643 arch: Some("amd64".to_string()),
644 };
645 assert_eq!(target.dist_subdir(), "linux_amd64");
646 }
647
648 #[test]
653 fn dist_subdir_os_only_matches_goreleaser_layout() {
654 let target = PartialTarget::OsArch {
655 os: "linux".to_string(),
656 arch: None,
657 };
658 assert_eq!(target.dist_subdir(), "linux");
659 }
660
661 #[test]
666 fn dist_subdir_exact_uses_full_rust_triple_not_goos_goarch() {
667 let target = PartialTarget::Exact("x86_64-unknown-linux-gnu".to_string());
668 assert_eq!(target.dist_subdir(), "x86_64-unknown-linux-gnu");
669 assert_ne!(target.dist_subdir(), "linux_amd64");
670 }
671
672 #[test]
677 fn test_targets_filter_matches_intersection() {
678 let target = PartialTarget::Targets(vec![
679 "x86_64-unknown-linux-gnu".to_string(),
680 "aarch64-unknown-linux-gnu".to_string(),
681 ]);
682 let configured = vec![
683 "x86_64-unknown-linux-gnu".to_string(),
684 "aarch64-unknown-linux-gnu".to_string(),
685 "x86_64-apple-darwin".to_string(),
686 "aarch64-apple-darwin".to_string(),
687 ];
688 let filtered = target.filter_targets(&configured);
689 assert_eq!(
690 filtered,
691 vec!["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]
692 );
693 }
694
695 #[test]
696 fn test_targets_filter_drops_non_configured_entries() {
697 let target = PartialTarget::Targets(vec![
700 "x86_64-unknown-linux-gnu".to_string(),
701 "x86_64-pc-windows-msvc".to_string(),
702 ]);
703 let configured = vec!["x86_64-unknown-linux-gnu".to_string()];
704 let filtered = target.filter_targets(&configured);
705 assert_eq!(filtered, vec!["x86_64-unknown-linux-gnu"]);
706 }
707
708 #[test]
709 fn test_targets_filter_empty_list_yields_empty() {
710 let target = PartialTarget::Targets(Vec::new());
711 let configured = vec!["x86_64-unknown-linux-gnu".to_string()];
712 assert!(target.filter_targets(&configured).is_empty());
713 }
714
715 #[test]
716 fn test_dist_subdir_targets_uses_first_triple() {
717 let target = PartialTarget::Targets(vec![
718 "x86_64-apple-darwin".to_string(),
719 "aarch64-apple-darwin".to_string(),
720 ]);
721 assert_eq!(target.dist_subdir(), "targets-x86_64-apple-darwin");
722 }
723
724 #[test]
725 fn test_dist_subdir_targets_empty_list_has_stable_name() {
726 let target = PartialTarget::Targets(Vec::new());
727 assert_eq!(target.dist_subdir(), "targets-empty");
728 }
729
730 #[test]
735 #[serial]
736 fn test_detect_host_target() {
737 let host = detect_host_target().unwrap();
740 assert!(!host.is_empty());
741 assert!(host.contains('-'), "host triple should contain '-': {host}");
743 }
744
745 #[test]
754 #[serial]
755 #[cfg(unix)]
756 fn detect_host_target_survives_deleted_cwd() {
757 let original = std::env::current_dir().unwrap();
758 let scratch = tempfile::tempdir().unwrap();
759 std::env::set_current_dir(scratch.path()).unwrap();
760 scratch.close().unwrap();
763
764 let result = detect_host_target();
765
766 std::env::set_current_dir(&original).unwrap();
768
769 let host = result.expect("host detection must succeed despite a deleted cwd");
770 assert!(host.contains('-'), "host triple should contain '-': {host}");
771 }
772
773 #[test]
778 fn test_resolve_with_os_default() {
779 let env = crate::MapEnvSource::new();
781
782 let config = None; let target = resolve_partial_target_with_env(&config, &env).unwrap();
784
785 match target {
787 PartialTarget::OsArch { os, arch } => {
788 assert!(!os.is_empty());
789 assert!(arch.is_none()); }
791 other => panic!("expected OsArch, got: {other:?}"),
792 }
793 }
794
795 #[test]
796 fn test_resolve_with_by_target() {
797 let env = crate::MapEnvSource::new();
798
799 let config = Some(PartialConfig {
800 by: Some("target".to_string()),
801 });
802 let target = resolve_partial_target_with_env(&config, &env).unwrap();
803
804 match target {
806 PartialTarget::Exact(t) => {
807 assert!(t.contains('-'), "should be full triple: {t}");
808 }
809 other => panic!("expected Exact, got: {other:?}"),
810 }
811 }
812
813 #[test]
814 fn test_resolve_invalid_by_value() {
815 let env = crate::MapEnvSource::new();
816
817 let config = Some(PartialConfig {
818 by: Some("invalid".to_string()),
819 });
820 let err = resolve_partial_target_with_env(&config, &env).unwrap_err();
821 assert!(err.to_string().contains("unknown value"), "got: {}", err);
822 }
823
824 #[test]
825 fn test_resolve_by_os_works_and_legacy_goos_rejected() {
826 let env = crate::MapEnvSource::new();
827
828 let ok = resolve_partial_target_with_env(
829 &Some(PartialConfig {
830 by: Some("os".to_string()),
831 }),
832 &env,
833 )
834 .unwrap();
835 assert!(matches!(ok, PartialTarget::OsArch { arch: None, .. }));
836
837 let err = resolve_partial_target_with_env(
840 &Some(PartialConfig {
841 by: Some("goos".to_string()),
842 }),
843 &env,
844 )
845 .unwrap_err();
846 assert!(err.to_string().contains("unknown value"), "got: {}", err);
847 }
848
849 #[test]
854 fn test_suggest_runner() {
855 assert_eq!(suggest_runner("linux"), "ubuntu-latest");
856 assert_eq!(suggest_runner("darwin"), "macos-latest");
857 assert_eq!(suggest_runner("windows"), "windows-latest");
858 assert_eq!(suggest_runner("freebsd"), "ubuntu-latest");
859 }
860
861 #[test]
866 fn resolve_host_target_honours_target_env_override() {
867 let env = crate::MapEnvSource::new().with("TARGET", "x86_64-unknown-linux-musl");
868 let triple = resolve_host_target_with_env(&env).unwrap();
869 assert_eq!(triple, "x86_64-unknown-linux-musl");
870 }
871
872 #[test]
873 fn resolve_host_target_target_env_wins_over_ggoos() {
874 let env = crate::MapEnvSource::new()
875 .with("TARGET", "aarch64-apple-darwin")
876 .with("GGOOS", "linux")
877 .with("GGOARCH", "amd64");
878 let triple = resolve_host_target_with_env(&env).unwrap();
879 assert_eq!(triple, "aarch64-apple-darwin");
880 }
881
882 #[test]
883 #[serial]
884 fn resolve_host_target_blank_target_falls_through() {
885 let env = crate::MapEnvSource::new().with("TARGET", " ");
888 let triple = resolve_host_target_with_env(&env).unwrap();
889 assert!(triple.contains('-'), "fell back to rustc -vV: {triple}");
890 }
891
892 #[test]
893 fn ggoos_overrides_host_os_component() {
894 let synthesized = synthesize_triple_with_overrides(
897 "x86_64-unknown-linux-gnu",
898 Some("darwin"),
899 Some("arm64"),
900 );
901 assert_eq!(synthesized, "aarch64-apple-darwin");
902 }
903
904 #[test]
905 fn ggoos_alone_keeps_host_arch() {
906 let synthesized =
907 synthesize_triple_with_overrides("x86_64-unknown-linux-gnu", Some("windows"), None);
908 assert_eq!(synthesized, "x86_64-pc-windows-msvc");
909 }
910
911 #[test]
912 fn ggoarch_alone_keeps_host_os() {
913 let synthesized =
914 synthesize_triple_with_overrides("x86_64-unknown-linux-gnu", None, Some("arm64"));
915 assert_eq!(synthesized, "aarch64-unknown-linux-gnu");
916 }
917
918 #[test]
923 fn find_runtime_matches_exact() {
924 let configured = vec![
925 "x86_64-unknown-linux-gnu".to_string(),
926 "aarch64-apple-darwin".to_string(),
927 ];
928 let m = find_runtime_target("x86_64-unknown-linux-gnu", &configured);
929 assert_eq!(m.as_deref(), Some("x86_64-unknown-linux-gnu"));
930 }
931
932 #[test]
933 fn find_runtime_matches_by_alias() {
934 let configured = vec!["x86_64-unknown-linux-gnu".to_string()];
938 let m = find_runtime_target("x86_64-unknown-linux-musl", &configured);
939 assert_eq!(m.as_deref(), Some("x86_64-unknown-linux-gnu"));
940 }
941
942 #[test]
943 fn find_runtime_returns_none_when_no_match() {
944 let configured = vec!["aarch64-apple-darwin".to_string()];
945 let m = find_runtime_target("x86_64-unknown-linux-gnu", &configured);
946 assert!(m.is_none());
947 }
948
949 const LINUX_HOST: &str = "x86_64-unknown-linux-gnu";
954 const MAC_HOST: &str = "aarch64-apple-darwin";
955 const WINDOWS_HOST: &str = "x86_64-pc-windows-msvc";
956
957 fn mixed_targets() -> Vec<String> {
960 vec![
961 "x86_64-unknown-linux-gnu".to_string(),
962 "aarch64-unknown-linux-gnu".to_string(),
963 "x86_64-pc-windows-gnu".to_string(),
964 "x86_64-pc-windows-msvc".to_string(),
965 "x86_64-apple-darwin".to_string(),
966 "aarch64-apple-darwin".to_string(),
967 ]
968 }
969
970 #[test]
971 fn host_buildable_linux_keeps_cross_buildable_skips_apple_and_msvc() {
972 let (kept, skipped) = host_buildable_targets(LINUX_HOST, &mixed_targets());
973 assert_eq!(
974 kept,
975 vec![
976 "x86_64-unknown-linux-gnu",
977 "aarch64-unknown-linux-gnu",
978 "x86_64-pc-windows-gnu",
979 ],
980 "linux + windows-gnu targets are cross-buildable from a linux host"
981 );
982 assert_eq!(
983 skipped,
984 vec![
985 "x86_64-pc-windows-msvc",
986 "x86_64-apple-darwin",
987 "aarch64-apple-darwin",
988 ],
989 "windows-msvc (needs Windows) and apple (needs macOS) are skipped on linux"
990 );
991 }
992
993 #[test]
994 fn host_buildable_apple_host_keeps_apple_still_skips_msvc() {
995 let (kept, skipped) = host_buildable_targets(MAC_HOST, &mixed_targets());
998 assert_eq!(
999 kept,
1000 vec![
1001 "x86_64-unknown-linux-gnu",
1002 "aarch64-unknown-linux-gnu",
1003 "x86_64-pc-windows-gnu",
1004 "x86_64-apple-darwin",
1005 "aarch64-apple-darwin",
1006 ],
1007 "apple host keeps apple + linux + windows-gnu: {kept:?}"
1008 );
1009 assert_eq!(
1010 skipped,
1011 vec!["x86_64-pc-windows-msvc"],
1012 "windows-msvc still needs a Windows host, even from macOS"
1013 );
1014 }
1015
1016 #[test]
1017 fn host_buildable_windows_host_keeps_msvc_skips_apple() {
1018 let (kept, skipped) = host_buildable_targets(WINDOWS_HOST, &mixed_targets());
1020 assert_eq!(
1021 kept,
1022 vec![
1023 "x86_64-unknown-linux-gnu",
1024 "aarch64-unknown-linux-gnu",
1025 "x86_64-pc-windows-gnu",
1026 "x86_64-pc-windows-msvc",
1027 ],
1028 "windows host keeps windows-msvc + linux + windows-gnu: {kept:?}"
1029 );
1030 assert_eq!(
1031 skipped,
1032 vec!["x86_64-apple-darwin", "aarch64-apple-darwin"],
1033 "apple targets still need a macOS host, even from Windows"
1034 );
1035 }
1036
1037 #[test]
1038 fn host_buildable_linux_only_config_keeps_all() {
1039 let configured = vec![
1040 "x86_64-unknown-linux-gnu".to_string(),
1041 "x86_64-pc-windows-gnu".to_string(),
1042 ];
1043 let (kept, skipped) = host_buildable_targets(LINUX_HOST, &configured);
1044 assert_eq!(kept, configured);
1045 assert!(skipped.is_empty());
1046 }
1047
1048 #[test]
1049 fn host_buildable_linux_apple_only_config_skips_all() {
1050 let configured = vec![
1051 "x86_64-apple-darwin".to_string(),
1052 "aarch64-apple-darwin".to_string(),
1053 ];
1054 let (kept, skipped) = host_buildable_targets(LINUX_HOST, &configured);
1055 assert!(kept.is_empty(), "a linux host can build no apple targets");
1056 assert_eq!(skipped, configured);
1057 }
1058
1059 #[test]
1060 fn host_buildable_linux_msvc_only_config_skips_all() {
1061 let configured = vec!["x86_64-pc-windows-msvc".to_string()];
1062 let (kept, skipped) = host_buildable_targets(LINUX_HOST, &configured);
1063 assert!(
1064 kept.is_empty(),
1065 "a linux host can build no windows-msvc targets"
1066 );
1067 assert_eq!(skipped, configured);
1068 }
1069
1070 #[test]
1071 fn host_targets_skip_message_names_both_reasons_on_linux() {
1072 let skipped = vec![
1074 "aarch64-apple-darwin".to_string(),
1075 "x86_64-apple-darwin".to_string(),
1076 "x86_64-pc-windows-msvc".to_string(),
1077 ];
1078 let msg = host_targets_skip_message(LINUX_HOST, &skipped).unwrap();
1079 assert!(msg.contains("3 target(s)"), "names the count: {msg}");
1080 assert!(msg.contains("linux host"), "names the host OS: {msg}");
1081 assert!(
1082 msg.contains("apple targets require a macOS host"),
1083 "names the apple reason: {msg}"
1084 );
1085 assert!(
1086 msg.contains("windows-msvc targets require a Windows host"),
1087 "names the msvc reason: {msg}"
1088 );
1089 assert!(msg.contains("aarch64-apple-darwin"), "lists triple: {msg}");
1090 assert!(msg.contains("x86_64-apple-darwin"), "lists triple: {msg}");
1091 assert!(
1092 msg.contains("x86_64-pc-windows-msvc"),
1093 "lists triple: {msg}"
1094 );
1095 assert_eq!(msg.lines().count(), 1, "stays a single line: {msg}");
1097 }
1098
1099 #[test]
1100 fn host_targets_skip_message_msvc_only_omits_apple_clause() {
1101 let skipped = vec!["x86_64-pc-windows-msvc".to_string()];
1103 let msg = host_targets_skip_message(LINUX_HOST, &skipped).unwrap();
1104 assert!(
1105 msg.contains("windows-msvc targets require a Windows host"),
1106 "names the msvc reason: {msg}"
1107 );
1108 assert!(
1109 !msg.contains("macOS"),
1110 "msvc-only skip must not mention macOS: {msg}"
1111 );
1112 }
1113
1114 #[test]
1115 fn host_targets_skip_message_is_none_when_nothing_skipped() {
1116 assert!(host_targets_skip_message(LINUX_HOST, &[]).is_none());
1117 }
1118
1119 #[test]
1120 fn parse_rustc_version_from_output_parses_release_line() {
1121 let sample = "\
1122rustc 1.96.0 (ac68faa20 2026-05-25)\n\
1123binary: rustc\n\
1124commit-hash: ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96\n\
1125commit-date: 2026-05-25\n\
1126host: x86_64-unknown-linux-gnu\n\
1127release: 1.96.0\n\
1128LLVM version: 22.1.2\n";
1129 assert_eq!(
1130 parse_rustc_version_from_output(sample),
1131 Some("1.96.0".to_string())
1132 );
1133 assert_eq!(
1135 parse_host_from_output(sample),
1136 Some("x86_64-unknown-linux-gnu".to_string())
1137 );
1138 }
1139
1140 #[test]
1141 fn parse_rustc_version_from_output_parses_prerelease_line() {
1142 let sample = "\
1143rustc 1.97.0-nightly (abc123 2026-06-01)\n\
1144release: 1.97.0-nightly\n\
1145host: aarch64-apple-darwin\n";
1146 assert_eq!(
1147 parse_rustc_version_from_output(sample),
1148 Some("1.97.0-nightly".to_string())
1149 );
1150 }
1151
1152 #[test]
1153 fn parse_rustc_version_from_output_returns_none_when_line_absent() {
1154 let sample = "binary: rustc\nhost: x86_64-unknown-linux-gnu\n";
1155 assert_eq!(parse_rustc_version_from_output(sample), None);
1156 }
1157
1158 #[test]
1159 #[serial]
1160 fn detect_rustc_version_live_returns_nonempty() {
1161 if let Some(ver) = detect_rustc_version() {
1163 assert!(!ver.is_empty(), "live rustc version should not be empty");
1164 assert!(
1165 ver.chars().next().is_some_and(|c| c.is_ascii_digit()),
1166 "live rustc version should start with a digit: {ver}"
1167 );
1168 }
1169 }
1170}