day_toolchain/lib.rs
1//! day-toolchain — ONE place that knows where host toolchains and SDKs live, shared by the
2//! `day` CLI and by crate build scripts (day-xaml-sys, every `day-piece-*`/`day-tweak-*` that
3//! compiles its own native shim, and the scaffolds `day new` generates).
4//!
5//! Two rules govern every lookup here (docs/environment.md):
6//! 1. **An environment variable always wins.** Each function documents its override(s).
7//! 2. **No literal install paths.** Default locations are derived from the platform's own
8//! environment (`%ProgramFiles%`, `$HOME`, `%LOCALAPPDATA%`) — never a hardwired `C:\…`,
9//! so relocated installs (Windows Kits on `D:`, a portable SDK) work by setting one var.
10//!
11//! Functions that are meant to be called from build scripts have `_for_build_script` variants
12//! that also emit the matching `cargo:rerun-if-env-changed=` lines, so changing an override
13//! re-runs the script instead of silently keeping stale results.
14
15use std::path::{Path, PathBuf};
16
17// ---------------------------------------------------------------------------
18// Windows Kits (the Windows 10/11 SDK): cppwinrt headers + bin tools
19// ---------------------------------------------------------------------------
20
21/// Candidate `Windows Kits\10`-style roots, best first.
22///
23/// Overrides: `DAY_WINDOWS_KITS_ROOT` (the `…\Windows Kits\10` directory itself), then the
24/// MS-standard `WindowsSdkDir` (set by Visual Studio developer shells). Fallbacks derive from
25/// `%ProgramFiles(x86)%` / `%ProgramFiles%` — the env vars, not literal `C:\` paths.
26pub fn windows_kits_roots() -> Vec<PathBuf> {
27 let mut roots = Vec::new();
28 if let Ok(v) = std::env::var("DAY_WINDOWS_KITS_ROOT") {
29 roots.push(PathBuf::from(v));
30 }
31 if let Ok(v) = std::env::var("WindowsSdkDir") {
32 roots.push(PathBuf::from(v));
33 }
34 for pf in ["ProgramFiles(x86)", "ProgramFiles"] {
35 if let Ok(v) = std::env::var(pf) {
36 roots.push(PathBuf::from(v).join("Windows Kits").join("10"));
37 }
38 }
39 roots.dedup();
40 roots
41}
42
43/// The newest `Include\<version>\cppwinrt` directory (the C++/WinRT projection headers), for
44/// compiling XAML shims with `cc`.
45///
46/// Overrides: `DAY_CPPWINRT` (the exact cppwinrt include dir — highest priority), then the
47/// roots from [`windows_kits_roots`]. Validated by `winrt/base.h`.
48pub fn cppwinrt_include() -> Option<PathBuf> {
49 if let Ok(v) = std::env::var("DAY_CPPWINRT") {
50 let p = PathBuf::from(v);
51 if p.join("winrt").join("base.h").exists() {
52 return Some(p);
53 }
54 // An explicit override that doesn't validate is a configuration error worth surfacing
55 // loudly in a build script; returning None lets the caller's expect() name the fix.
56 return None;
57 }
58 let mut found: Vec<PathBuf> = Vec::new();
59 for root in windows_kits_roots() {
60 let Ok(rd) = std::fs::read_dir(root.join("Include")) else {
61 continue;
62 };
63 for entry in rd.flatten() {
64 let cppwinrt = entry.path().join("cppwinrt");
65 if cppwinrt.join("winrt").join("base.h").exists() {
66 found.push(cppwinrt);
67 }
68 }
69 }
70 found.sort(); // version dirs sort lexicographically; newest last
71 found.pop()
72}
73
74/// [`cppwinrt_include`] for build scripts: also emits the `rerun-if-env-changed` lines so an
75/// override change re-runs the script.
76pub fn cppwinrt_include_for_build_script() -> Option<PathBuf> {
77 for var in ["DAY_CPPWINRT", "DAY_WINDOWS_KITS_ROOT", "WindowsSdkDir"] {
78 println!("cargo:rerun-if-env-changed={var}");
79 }
80 cppwinrt_include()
81}
82
83/// A Windows-Kits bin tool (`signtool.exe`, `makeappx.exe`, …): newest SDK version, host arch.
84///
85/// Overrides: `DAY_WINDOWS_KIT` (a bin directory containing the tool), then the tool on `PATH`,
86/// then `bin\<version>\<arch>` under each [`windows_kits_roots`] root.
87pub fn windows_kit_tool(tool: &str) -> Option<PathBuf> {
88 if let Ok(root) = std::env::var("DAY_WINDOWS_KIT") {
89 let p = PathBuf::from(root).join(tool);
90 if p.exists() {
91 return Some(p);
92 }
93 }
94 if let Some(p) = on_path(tool) {
95 return Some(p);
96 }
97 let arch = if cfg!(target_arch = "aarch64") {
98 "arm64"
99 } else {
100 "x64"
101 };
102 for root in windows_kits_roots() {
103 let Ok(rd) = std::fs::read_dir(root.join("bin")) else {
104 continue;
105 };
106 let mut versions: Vec<PathBuf> = rd
107 .flatten()
108 .map(|e| e.path())
109 .filter(|p| {
110 p.file_name()
111 .is_some_and(|n| n.to_string_lossy().starts_with("10."))
112 })
113 .collect();
114 versions.sort();
115 while let Some(v) = versions.pop() {
116 let candidate = v.join(arch).join(tool);
117 if candidate.exists() {
118 return Some(candidate);
119 }
120 }
121 }
122 None
123}
124
125// ---------------------------------------------------------------------------
126// NSIS
127// ---------------------------------------------------------------------------
128
129/// The `makensis` NSIS compiler (cross-platform: apt/brew/choco all put it on PATH).
130///
131/// Overrides: `DAY_MAKENSIS` (the executable itself), then `PATH`, then the conventional
132/// Windows install dir under `%ProgramFiles(x86)%` / `%ProgramFiles%`.
133pub fn makensis() -> Option<PathBuf> {
134 if let Ok(v) = std::env::var("DAY_MAKENSIS") {
135 let p = PathBuf::from(v);
136 if p.is_file() {
137 return Some(p);
138 }
139 return None; // explicit override that doesn't exist = configuration error, don't mask it
140 }
141 if let Some(p) = on_path("makensis").or_else(|| on_path("makensis.exe")) {
142 return Some(p);
143 }
144 for pf in ["ProgramFiles(x86)", "ProgramFiles"] {
145 if let Ok(v) = std::env::var(pf) {
146 let p = PathBuf::from(v).join("NSIS").join("makensis.exe");
147 if p.exists() {
148 return Some(p);
149 }
150 }
151 }
152 None
153}
154
155// ---------------------------------------------------------------------------
156// Android SDK + JDK
157// ---------------------------------------------------------------------------
158
159/// The Android SDK root.
160///
161/// Overrides: `ANDROID_HOME`, then `ANDROID_SDK_ROOT` (both standard). Falls back to each
162/// platform's default install location: `~/Library/Android/sdk` (macOS),
163/// `%LOCALAPPDATA%\Android\Sdk` (Windows), `~/Android/Sdk` (Linux — Android Studio's default).
164pub fn android_sdk_dir() -> PathBuf {
165 if let Ok(v) = std::env::var("ANDROID_HOME").or_else(|_| std::env::var("ANDROID_SDK_ROOT")) {
166 return PathBuf::from(v);
167 }
168 if cfg!(target_os = "windows")
169 && let Ok(v) = std::env::var("LOCALAPPDATA")
170 {
171 return PathBuf::from(v).join("Android").join("Sdk");
172 }
173 let home = PathBuf::from(std::env::var("HOME").unwrap_or_default());
174 if cfg!(target_os = "macos") {
175 home.join("Library/Android/sdk")
176 } else {
177 home.join("Android/Sdk")
178 }
179}
180
181/// A JDK home for the Gradle/AGP build. AGP 9's minimum is JDK 17, and Gradle must support the
182/// exact version — Gradle 9.6 runs on 17…26 (verified: the day scaffold builds on 17, 21 and 26
183/// alike, so the old "21 exactly / 22+ breaks the jdk-image transform" restriction was an AGP-8-era
184/// carryover and no longer holds).
185///
186/// Overrides: `JAVA_HOME` (trusted as-is — Gradle's own contract). Fallbacks: macOS's
187/// `/usr/libexec/java_home -v 17+` registry (the newest install ≥ 17), then a Homebrew `openjdk`
188/// keg — the unversioned latest first, then pinned 17+ kegs (both Apple-Silicon and Intel
189/// prefixes). Callers export the result as `JAVA_HOME` for the Gradle child process.
190pub fn jdk_home() -> Option<PathBuf> {
191 if let Ok(v) = std::env::var("JAVA_HOME") {
192 return Some(PathBuf::from(v));
193 }
194 if cfg!(target_os = "macos") {
195 // The canonical macOS JDK registry (also finds Temurin/Zulu installs, not just brew).
196 if let Ok(out) = std::process::Command::new("/usr/libexec/java_home")
197 .args(["-v", "17+"])
198 .output()
199 && out.status.success()
200 {
201 let p = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim());
202 if p.join("bin/java").exists() {
203 return Some(p);
204 }
205 }
206 // Newest keg first: the unversioned `openjdk` is Homebrew's current, then LTS/common pins.
207 for keg in ["openjdk", "openjdk@21", "openjdk@17"] {
208 for prefix in ["/opt/homebrew", "/usr/local"] {
209 let p = PathBuf::from(prefix).join("opt").join(keg);
210 if p.join("bin/java").exists() {
211 return Some(p);
212 }
213 }
214 }
215 }
216 None
217}
218
219// ---------------------------------------------------------------------------
220// rustup
221// ---------------------------------------------------------------------------
222
223/// The rustup toolchain to use for cross-std builds (mobile targets need rustup's target std;
224/// a Homebrew/system rustc has none), as `(cargo_path, bin_dir)`. The bin dir is prepended to
225/// `PATH` so the toolchain's own `rustc` — not one earlier on `PATH` — is what cargo invokes.
226///
227/// Overrides: `RUSTUP_HOME` (standard; default `~/.rustup`). Among installed toolchains a
228/// `stable-*` one is preferred, then the lexicographically first — deterministic where the old
229/// first-directory-wins behavior depended on filesystem order.
230pub fn rustup_cargo() -> Result<(PathBuf, PathBuf), String> {
231 let rustup_home = std::env::var("RUSTUP_HOME")
232 .map(PathBuf::from)
233 .or_else(|_| {
234 std::env::var("HOME")
235 .map(|h| PathBuf::from(h).join(".rustup"))
236 .map_err(|e| e.to_string())
237 })?;
238 let toolchains = rustup_home.join("toolchains");
239 let mut entries: Vec<PathBuf> = std::fs::read_dir(&toolchains)
240 .map_err(|_| "no rustup toolchains (cross-std needs rustup, not Homebrew rust)")?
241 .flatten()
242 .map(|e| e.path())
243 .collect();
244 entries.sort();
245 let chosen = entries
246 .iter()
247 .find(|p| {
248 p.file_name()
249 .is_some_and(|n| n.to_string_lossy().starts_with("stable-"))
250 })
251 .or_else(|| entries.first())
252 .ok_or("empty rustup toolchains dir")?;
253 let bin = chosen.join("bin");
254 Ok((bin.join("cargo"), bin))
255}
256
257// ---------------------------------------------------------------------------
258
259fn on_path(tool: &str) -> Option<PathBuf> {
260 let path = std::env::var_os("PATH")?;
261 std::env::split_paths(&path)
262 .map(|d| d.join(tool))
263 .find(|p| p.is_file())
264}
265
266/// True when `dir` looks like a usable directory (exists and is a dir) — small helper for
267/// callers validating overrides.
268pub fn is_dir(dir: &Path) -> bool {
269 dir.is_dir()
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn kits_roots_honor_override_first() {
278 // SAFETY: test-local env mutation; tests touch distinct vars.
279 unsafe { std::env::set_var("DAY_WINDOWS_KITS_ROOT", "/custom/kits/10") };
280 let roots = windows_kits_roots();
281 assert_eq!(roots[0], PathBuf::from("/custom/kits/10"));
282 unsafe { std::env::remove_var("DAY_WINDOWS_KITS_ROOT") };
283 }
284
285 #[test]
286 fn android_sdk_honors_android_home() {
287 unsafe { std::env::set_var("ANDROID_HOME", "/custom/android") };
288 assert_eq!(android_sdk_dir(), PathBuf::from("/custom/android"));
289 unsafe { std::env::remove_var("ANDROID_HOME") };
290 }
291
292 #[test]
293 fn explicit_cppwinrt_override_must_validate() {
294 unsafe { std::env::set_var("DAY_CPPWINRT", "/does/not/exist") };
295 assert_eq!(cppwinrt_include(), None); // bad override surfaces, not masked by fallbacks
296 unsafe { std::env::remove_var("DAY_CPPWINRT") };
297 }
298}