day_toolchain/lib.rs
1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! day-toolchain — ONE place that knows where host toolchains and SDKs live, shared by the
5//! `day` CLI and by crate build scripts (day-xaml-sys, every `day-piece-*`/`day-tweak-*` that
6//! compiles its own native shim, and the scaffolds `day new` generates).
7//!
8//! Two rules govern every lookup here (docs/environment.md):
9//! 1. **An environment variable always wins.** Each function documents its override(s).
10//! 2. **No literal install paths.** Default locations are derived from the platform's own
11//! environment (`%ProgramFiles%`, `$HOME`, `%LOCALAPPDATA%`) — never a hardwired `C:\…`,
12//! so relocated installs (Windows Kits on `D:`, a portable SDK) work by setting one var.
13//!
14//! Functions that are meant to be called from build scripts have `_for_build_script` variants
15//! that also emit the matching `cargo:rerun-if-env-changed=` lines, so changing an override
16//! re-runs the script instead of silently keeping stale results.
17
18use std::path::{Path, PathBuf};
19
20// ---------------------------------------------------------------------------
21// Windows Kits (the Windows 10/11 SDK): cppwinrt headers + bin tools
22// ---------------------------------------------------------------------------
23
24/// Candidate `Windows Kits\10`-style roots, best first.
25///
26/// Overrides: `DAY_WINDOWS_KITS_ROOT` (the `…\Windows Kits\10` directory itself), then the
27/// MS-standard `WindowsSdkDir` (set by Visual Studio developer shells). Fallbacks derive from
28/// `%ProgramFiles(x86)%` / `%ProgramFiles%` — the env vars, not literal `C:\` paths.
29pub fn windows_kits_roots() -> Vec<PathBuf> {
30 let mut roots = Vec::new();
31 if let Ok(v) = std::env::var("DAY_WINDOWS_KITS_ROOT") {
32 roots.push(PathBuf::from(v));
33 }
34 if let Ok(v) = std::env::var("WindowsSdkDir") {
35 roots.push(PathBuf::from(v));
36 }
37 for pf in ["ProgramFiles(x86)", "ProgramFiles"] {
38 if let Ok(v) = std::env::var(pf) {
39 roots.push(PathBuf::from(v).join("Windows Kits").join("10"));
40 }
41 }
42 roots.dedup();
43 roots
44}
45
46/// The newest `Include\<version>\cppwinrt` directory (the C++/WinRT projection headers), for
47/// compiling XAML shims with `cc`.
48///
49/// Overrides: `DAY_CPPWINRT` (the exact cppwinrt include dir — highest priority), then the
50/// roots from [`windows_kits_roots`]. Validated by `winrt/base.h`.
51pub fn cppwinrt_include() -> Option<PathBuf> {
52 if let Ok(v) = std::env::var("DAY_CPPWINRT") {
53 let p = PathBuf::from(v);
54 if p.join("winrt").join("base.h").exists() {
55 return Some(p);
56 }
57 // An explicit override that doesn't validate is a configuration error worth surfacing
58 // loudly in a build script; returning None lets the caller's expect() name the fix.
59 return None;
60 }
61 let mut found: Vec<PathBuf> = Vec::new();
62 for root in windows_kits_roots() {
63 let Ok(rd) = std::fs::read_dir(root.join("Include")) else {
64 continue;
65 };
66 for entry in rd.flatten() {
67 let cppwinrt = entry.path().join("cppwinrt");
68 if cppwinrt.join("winrt").join("base.h").exists() {
69 found.push(cppwinrt);
70 }
71 }
72 }
73 found.sort(); // version dirs sort lexicographically; newest last
74 found.pop()
75}
76
77/// [`cppwinrt_include`] for build scripts: also emits the `rerun-if-env-changed` lines so an
78/// override change re-runs the script.
79pub fn cppwinrt_include_for_build_script() -> Option<PathBuf> {
80 for var in ["DAY_CPPWINRT", "DAY_WINDOWS_KITS_ROOT", "WindowsSdkDir"] {
81 println!("cargo:rerun-if-env-changed={var}");
82 }
83 cppwinrt_include()
84}
85
86/// A Windows-Kits bin tool (`signtool.exe`, `makeappx.exe`, …): newest SDK version, host arch.
87///
88/// Overrides: `DAY_WINDOWS_KIT` (a bin directory containing the tool), then the tool on `PATH`,
89/// then `bin\<version>\<arch>` under each [`windows_kits_roots`] root.
90pub fn windows_kit_tool(tool: &str) -> Option<PathBuf> {
91 if let Ok(root) = std::env::var("DAY_WINDOWS_KIT") {
92 let p = PathBuf::from(root).join(tool);
93 if p.exists() {
94 return Some(p);
95 }
96 }
97 if let Some(p) = on_path(tool) {
98 return Some(p);
99 }
100 let arch = if cfg!(target_arch = "aarch64") {
101 "arm64"
102 } else {
103 "x64"
104 };
105 for root in windows_kits_roots() {
106 let Ok(rd) = std::fs::read_dir(root.join("bin")) else {
107 continue;
108 };
109 let mut versions: Vec<PathBuf> = rd
110 .flatten()
111 .map(|e| e.path())
112 .filter(|p| {
113 p.file_name()
114 .is_some_and(|n| n.to_string_lossy().starts_with("10."))
115 })
116 .collect();
117 versions.sort();
118 while let Some(v) = versions.pop() {
119 let candidate = v.join(arch).join(tool);
120 if candidate.exists() {
121 return Some(candidate);
122 }
123 }
124 }
125 None
126}
127
128// ---------------------------------------------------------------------------
129// NSIS
130// ---------------------------------------------------------------------------
131
132/// The `makensis` NSIS compiler (cross-platform: apt/brew/choco all put it on PATH).
133///
134/// Overrides: `DAY_MAKENSIS` (the executable itself), then `PATH`, then the conventional
135/// Windows install dir under `%ProgramFiles(x86)%` / `%ProgramFiles%`.
136pub fn makensis() -> Option<PathBuf> {
137 if let Ok(v) = std::env::var("DAY_MAKENSIS") {
138 let p = PathBuf::from(v);
139 if p.is_file() {
140 return Some(p);
141 }
142 return None; // explicit override that doesn't exist = configuration error, don't mask it
143 }
144 if let Some(p) = on_path("makensis").or_else(|| on_path("makensis.exe")) {
145 return Some(p);
146 }
147 for pf in ["ProgramFiles(x86)", "ProgramFiles"] {
148 if let Ok(v) = std::env::var(pf) {
149 let p = PathBuf::from(v).join("NSIS").join("makensis.exe");
150 if p.exists() {
151 return Some(p);
152 }
153 }
154 }
155 // Chocolatey (`choco install nsis`) — the way CI and most Windows devs get it. Its shim lands
156 // in the chocolatey bin dir, which IS on the machine PATH, but a PATH edit made by an install
157 // does not reach an ALREADY-RUNNING process: GitHub Actions hands every step the environment
158 // captured when the job started, so `choco install` in one step leaves the next step's PATH
159 // untouched. Probing the location directly is what makes the install usable in the same job.
160 let choco = std::env::var("ChocolateyInstall")
161 .map(PathBuf::from)
162 .unwrap_or_else(|_| PathBuf::from(r"C:\ProgramData\chocolatey"));
163 let shim = choco.join("bin").join("makensis.exe");
164 if shim.is_file() {
165 return Some(shim);
166 }
167 // The package's own tree, when it unpacks rather than shimming. The directory under `tools`
168 // carries the NSIS version, so scan one level instead of guessing it.
169 let tools = choco.join("lib").join("nsis").join("tools");
170 if let Ok(entries) = std::fs::read_dir(&tools) {
171 for entry in entries.flatten() {
172 for candidate in [
173 entry.path().join("makensis.exe"),
174 entry.path().join("Bin").join("makensis.exe"),
175 ] {
176 if candidate.is_file() {
177 return Some(candidate);
178 }
179 }
180 }
181 }
182 None
183}
184
185// ---------------------------------------------------------------------------
186// Android SDK + JDK
187// ---------------------------------------------------------------------------
188
189/// The Android SDK root.
190///
191/// Overrides: `ANDROID_HOME`, then `ANDROID_SDK_ROOT` (both standard). Falls back to each
192/// platform's default install location: `~/Library/Android/sdk` (macOS),
193/// `%LOCALAPPDATA%\Android\Sdk` (Windows), `~/Android/Sdk` (Linux — Android Studio's default).
194pub fn android_sdk_dir() -> PathBuf {
195 if let Ok(v) = std::env::var("ANDROID_HOME").or_else(|_| std::env::var("ANDROID_SDK_ROOT")) {
196 return PathBuf::from(v);
197 }
198 if cfg!(target_os = "windows")
199 && let Ok(v) = std::env::var("LOCALAPPDATA")
200 {
201 return PathBuf::from(v).join("Android").join("Sdk");
202 }
203 let home = PathBuf::from(std::env::var("HOME").unwrap_or_default());
204 if cfg!(target_os = "macos") {
205 home.join("Library/Android/sdk")
206 } else {
207 home.join("Android/Sdk")
208 }
209}
210
211/// A JDK home for the Gradle/AGP build. AGP 9's minimum is JDK 17, and Gradle must support the
212/// exact version — Gradle 9.6 runs on 17…26 (verified: the day scaffold builds on 17, 21 and 26
213/// alike, so the old "21 exactly / 22+ breaks the jdk-image transform" restriction was an AGP-8-era
214/// carryover and no longer holds).
215///
216/// Overrides: `JAVA_HOME` (trusted as-is — Gradle's own contract). Fallbacks: macOS's
217/// `/usr/libexec/java_home -v 17+` registry (the newest install ≥ 17), then a Homebrew `openjdk`
218/// keg — the unversioned latest first, then pinned 17+ kegs (both Apple-Silicon and Intel
219/// prefixes). Callers export the result as `JAVA_HOME` for the Gradle child process.
220pub fn jdk_home() -> Option<PathBuf> {
221 if let Ok(v) = std::env::var("JAVA_HOME") {
222 return Some(PathBuf::from(v));
223 }
224 if cfg!(target_os = "macos") {
225 // The canonical macOS JDK registry (also finds Temurin/Zulu installs, not just brew).
226 if let Ok(out) = std::process::Command::new("/usr/libexec/java_home")
227 .args(["-v", "17+"])
228 .output()
229 && out.status.success()
230 {
231 let p = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim());
232 if p.join("bin/java").exists() {
233 return Some(p);
234 }
235 }
236 // Newest keg first: the unversioned `openjdk` is Homebrew's current, then LTS/common pins.
237 for keg in ["openjdk", "openjdk@21", "openjdk@17"] {
238 for prefix in ["/opt/homebrew", "/usr/local"] {
239 let p = PathBuf::from(prefix).join("opt").join(keg);
240 if p.join("bin/java").exists() {
241 return Some(p);
242 }
243 }
244 }
245 }
246 None
247}
248
249// ---------------------------------------------------------------------------
250// rustup
251// ---------------------------------------------------------------------------
252
253/// The rustup toolchain to use for cross-std builds (mobile targets need rustup's target std;
254/// a Homebrew/system rustc has none), as `(cargo_path, bin_dir)`. The bin dir is prepended to
255/// `PATH` so the toolchain's own `rustc` — not one earlier on `PATH` — is what cargo invokes.
256///
257/// Overrides: `RUSTUP_HOME` (standard; default `~/.rustup`). Among installed toolchains a
258/// `stable-*` one is preferred, then the lexicographically first — deterministic where the old
259/// first-directory-wins behavior depended on filesystem order.
260pub fn rustup_cargo() -> Result<(PathBuf, PathBuf), String> {
261 let rustup_home = std::env::var("RUSTUP_HOME")
262 .map(PathBuf::from)
263 .or_else(|_| {
264 std::env::var("HOME")
265 .map(|h| PathBuf::from(h).join(".rustup"))
266 .map_err(|e| e.to_string())
267 })?;
268 let toolchains = rustup_home.join("toolchains");
269 let mut entries: Vec<PathBuf> = std::fs::read_dir(&toolchains)
270 .map_err(|_| "no rustup toolchains (cross-std needs rustup, not Homebrew rust)")?
271 .flatten()
272 .map(|e| e.path())
273 .collect();
274 entries.sort();
275 let chosen = entries
276 .iter()
277 .find(|p| {
278 p.file_name()
279 .is_some_and(|n| n.to_string_lossy().starts_with("stable-"))
280 })
281 .or_else(|| entries.first())
282 .ok_or("empty rustup toolchains dir")?;
283 let bin = chosen.join("bin");
284 Ok((bin.join("cargo"), bin))
285}
286
287// ---------------------------------------------------------------------------
288
289fn on_path(tool: &str) -> Option<PathBuf> {
290 let path = std::env::var_os("PATH")?;
291 std::env::split_paths(&path)
292 .map(|d| d.join(tool))
293 .find(|p| p.is_file())
294}
295
296/// True when `dir` looks like a usable directory (exists and is a dir) — small helper for
297/// callers validating overrides.
298pub fn is_dir(dir: &Path) -> bool {
299 dir.is_dir()
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn kits_roots_honor_override_first() {
308 // SAFETY: test-local env mutation; tests touch distinct vars.
309 unsafe { std::env::set_var("DAY_WINDOWS_KITS_ROOT", "/custom/kits/10") };
310 let roots = windows_kits_roots();
311 assert_eq!(roots[0], PathBuf::from("/custom/kits/10"));
312 unsafe { std::env::remove_var("DAY_WINDOWS_KITS_ROOT") };
313 }
314
315 #[test]
316 fn android_sdk_honors_android_home() {
317 unsafe { std::env::set_var("ANDROID_HOME", "/custom/android") };
318 assert_eq!(android_sdk_dir(), PathBuf::from("/custom/android"));
319 unsafe { std::env::remove_var("ANDROID_HOME") };
320 }
321
322 #[test]
323 fn explicit_cppwinrt_override_must_validate() {
324 unsafe { std::env::set_var("DAY_CPPWINRT", "/does/not/exist") };
325 assert_eq!(cppwinrt_include(), None); // bad override surfaces, not masked by fallbacks
326 unsafe { std::env::remove_var("DAY_CPPWINRT") };
327 }
328
329 #[test]
330 fn explicit_makensis_override_must_validate() {
331 unsafe { std::env::set_var("DAY_MAKENSIS", "/does/not/exist/makensis.exe") };
332 assert_eq!(makensis(), None); // same contract as the other overrides: never masked
333 unsafe { std::env::remove_var("DAY_MAKENSIS") };
334 }
335
336 /// The layouts `choco install nsis` can leave behind. Each is built for real under a temp
337 /// `ChocolateyInstall` so the probe is exercised rather than assumed — this is the lookup that
338 /// failed a release build after NSIS had actually been installed.
339 #[test]
340 fn makensis_found_in_chocolatey_layouts() {
341 let base = std::env::temp_dir().join(format!("day-choco-probe-{}", std::process::id()));
342 let shimmed = base.join("shim");
343 let unpacked = base.join("unpacked");
344 let nested = base.join("nested");
345 let _ = std::fs::remove_dir_all(&base);
346
347 // 1. the shim chocolatey drops in its bin dir
348 let shim_exe = shimmed.join("bin").join("makensis.exe");
349 std::fs::create_dir_all(shim_exe.parent().unwrap()).unwrap();
350 std::fs::write(&shim_exe, b"").unwrap();
351
352 // 2. unpacked under lib/nsis/tools/<versioned dir>/
353 let flat = unpacked
354 .join("lib/nsis/tools")
355 .join("nsis-3.10")
356 .join("makensis.exe");
357 std::fs::create_dir_all(flat.parent().unwrap()).unwrap();
358 std::fs::write(&flat, b"").unwrap();
359
360 // 3. …with the executable one level deeper, in Bin/
361 let deep = nested
362 .join("lib/nsis/tools")
363 .join("nsis-3.10")
364 .join("Bin")
365 .join("makensis.exe");
366 std::fs::create_dir_all(deep.parent().unwrap()).unwrap();
367 std::fs::write(&deep, b"").unwrap();
368
369 // The earlier probes must not answer first, or this proves nothing — and on a machine that
370 // really has NSIS in Program Files they would. Saved and put back below: PATH in particular
371 // is process-global, and leaving it empty would poison every test that runs after this one.
372 let (path, pf, pf86) = (
373 std::env::var_os("PATH"),
374 std::env::var_os("ProgramFiles"),
375 std::env::var_os("ProgramFiles(x86)"),
376 );
377 unsafe {
378 std::env::remove_var("DAY_MAKENSIS");
379 std::env::set_var("PATH", "");
380 std::env::set_var("ProgramFiles", base.join("no-such-pf"));
381 std::env::set_var("ProgramFiles(x86)", base.join("no-such-pf86"));
382 }
383
384 let found: Vec<_> = [(&shimmed, &shim_exe), (&unpacked, &flat), (&nested, &deep)]
385 .iter()
386 .map(|(root, want)| {
387 unsafe { std::env::set_var("ChocolateyInstall", root) };
388 (makensis(), (*want).clone())
389 })
390 .collect();
391
392 unsafe {
393 std::env::remove_var("ChocolateyInstall");
394 match path {
395 Some(v) => std::env::set_var("PATH", v),
396 None => std::env::remove_var("PATH"),
397 }
398 match pf {
399 Some(v) => std::env::set_var("ProgramFiles", v),
400 None => std::env::remove_var("ProgramFiles"),
401 }
402 match pf86 {
403 Some(v) => std::env::set_var("ProgramFiles(x86)", v),
404 None => std::env::remove_var("ProgramFiles(x86)"),
405 }
406 }
407 let _ = std::fs::remove_dir_all(&base);
408
409 // Asserted only after the environment is back, so a failure can't take the rest with it.
410 for (got, want) in found {
411 assert_eq!(got.as_ref(), Some(&want), "chocolatey layout {want:?}");
412 }
413 }
414}