Skip to main content

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    // Chocolatey (`choco install nsis`) — the way CI and most Windows devs get it. Its shim lands
153    // in the chocolatey bin dir, which IS on the machine PATH, but a PATH edit made by an install
154    // does not reach an ALREADY-RUNNING process: GitHub Actions hands every step the environment
155    // captured when the job started, so `choco install` in one step leaves the next step's PATH
156    // untouched. Probing the location directly is what makes the install usable in the same job.
157    let choco = std::env::var("ChocolateyInstall")
158        .map(PathBuf::from)
159        .unwrap_or_else(|_| PathBuf::from(r"C:\ProgramData\chocolatey"));
160    let shim = choco.join("bin").join("makensis.exe");
161    if shim.is_file() {
162        return Some(shim);
163    }
164    // The package's own tree, when it unpacks rather than shimming. The directory under `tools`
165    // carries the NSIS version, so scan one level instead of guessing it.
166    let tools = choco.join("lib").join("nsis").join("tools");
167    if let Ok(entries) = std::fs::read_dir(&tools) {
168        for entry in entries.flatten() {
169            for candidate in [
170                entry.path().join("makensis.exe"),
171                entry.path().join("Bin").join("makensis.exe"),
172            ] {
173                if candidate.is_file() {
174                    return Some(candidate);
175                }
176            }
177        }
178    }
179    None
180}
181
182// ---------------------------------------------------------------------------
183// Android SDK + JDK
184// ---------------------------------------------------------------------------
185
186/// The Android SDK root.
187///
188/// Overrides: `ANDROID_HOME`, then `ANDROID_SDK_ROOT` (both standard). Falls back to each
189/// platform's default install location: `~/Library/Android/sdk` (macOS),
190/// `%LOCALAPPDATA%\Android\Sdk` (Windows), `~/Android/Sdk` (Linux — Android Studio's default).
191pub fn android_sdk_dir() -> PathBuf {
192    if let Ok(v) = std::env::var("ANDROID_HOME").or_else(|_| std::env::var("ANDROID_SDK_ROOT")) {
193        return PathBuf::from(v);
194    }
195    if cfg!(target_os = "windows")
196        && let Ok(v) = std::env::var("LOCALAPPDATA")
197    {
198        return PathBuf::from(v).join("Android").join("Sdk");
199    }
200    let home = PathBuf::from(std::env::var("HOME").unwrap_or_default());
201    if cfg!(target_os = "macos") {
202        home.join("Library/Android/sdk")
203    } else {
204        home.join("Android/Sdk")
205    }
206}
207
208/// A JDK home for the Gradle/AGP build. AGP 9's minimum is JDK 17, and Gradle must support the
209/// exact version — Gradle 9.6 runs on 17…26 (verified: the day scaffold builds on 17, 21 and 26
210/// alike, so the old "21 exactly / 22+ breaks the jdk-image transform" restriction was an AGP-8-era
211/// carryover and no longer holds).
212///
213/// Overrides: `JAVA_HOME` (trusted as-is — Gradle's own contract). Fallbacks: macOS's
214/// `/usr/libexec/java_home -v 17+` registry (the newest install ≥ 17), then a Homebrew `openjdk`
215/// keg — the unversioned latest first, then pinned 17+ kegs (both Apple-Silicon and Intel
216/// prefixes). Callers export the result as `JAVA_HOME` for the Gradle child process.
217pub fn jdk_home() -> Option<PathBuf> {
218    if let Ok(v) = std::env::var("JAVA_HOME") {
219        return Some(PathBuf::from(v));
220    }
221    if cfg!(target_os = "macos") {
222        // The canonical macOS JDK registry (also finds Temurin/Zulu installs, not just brew).
223        if let Ok(out) = std::process::Command::new("/usr/libexec/java_home")
224            .args(["-v", "17+"])
225            .output()
226            && out.status.success()
227        {
228            let p = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim());
229            if p.join("bin/java").exists() {
230                return Some(p);
231            }
232        }
233        // Newest keg first: the unversioned `openjdk` is Homebrew's current, then LTS/common pins.
234        for keg in ["openjdk", "openjdk@21", "openjdk@17"] {
235            for prefix in ["/opt/homebrew", "/usr/local"] {
236                let p = PathBuf::from(prefix).join("opt").join(keg);
237                if p.join("bin/java").exists() {
238                    return Some(p);
239                }
240            }
241        }
242    }
243    None
244}
245
246// ---------------------------------------------------------------------------
247// rustup
248// ---------------------------------------------------------------------------
249
250/// The rustup toolchain to use for cross-std builds (mobile targets need rustup's target std;
251/// a Homebrew/system rustc has none), as `(cargo_path, bin_dir)`. The bin dir is prepended to
252/// `PATH` so the toolchain's own `rustc` — not one earlier on `PATH` — is what cargo invokes.
253///
254/// Overrides: `RUSTUP_HOME` (standard; default `~/.rustup`). Among installed toolchains a
255/// `stable-*` one is preferred, then the lexicographically first — deterministic where the old
256/// first-directory-wins behavior depended on filesystem order.
257pub fn rustup_cargo() -> Result<(PathBuf, PathBuf), String> {
258    let rustup_home = std::env::var("RUSTUP_HOME")
259        .map(PathBuf::from)
260        .or_else(|_| {
261            std::env::var("HOME")
262                .map(|h| PathBuf::from(h).join(".rustup"))
263                .map_err(|e| e.to_string())
264        })?;
265    let toolchains = rustup_home.join("toolchains");
266    let mut entries: Vec<PathBuf> = std::fs::read_dir(&toolchains)
267        .map_err(|_| "no rustup toolchains (cross-std needs rustup, not Homebrew rust)")?
268        .flatten()
269        .map(|e| e.path())
270        .collect();
271    entries.sort();
272    let chosen = entries
273        .iter()
274        .find(|p| {
275            p.file_name()
276                .is_some_and(|n| n.to_string_lossy().starts_with("stable-"))
277        })
278        .or_else(|| entries.first())
279        .ok_or("empty rustup toolchains dir")?;
280    let bin = chosen.join("bin");
281    Ok((bin.join("cargo"), bin))
282}
283
284// ---------------------------------------------------------------------------
285
286fn on_path(tool: &str) -> Option<PathBuf> {
287    let path = std::env::var_os("PATH")?;
288    std::env::split_paths(&path)
289        .map(|d| d.join(tool))
290        .find(|p| p.is_file())
291}
292
293/// True when `dir` looks like a usable directory (exists and is a dir) — small helper for
294/// callers validating overrides.
295pub fn is_dir(dir: &Path) -> bool {
296    dir.is_dir()
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn kits_roots_honor_override_first() {
305        // SAFETY: test-local env mutation; tests touch distinct vars.
306        unsafe { std::env::set_var("DAY_WINDOWS_KITS_ROOT", "/custom/kits/10") };
307        let roots = windows_kits_roots();
308        assert_eq!(roots[0], PathBuf::from("/custom/kits/10"));
309        unsafe { std::env::remove_var("DAY_WINDOWS_KITS_ROOT") };
310    }
311
312    #[test]
313    fn android_sdk_honors_android_home() {
314        unsafe { std::env::set_var("ANDROID_HOME", "/custom/android") };
315        assert_eq!(android_sdk_dir(), PathBuf::from("/custom/android"));
316        unsafe { std::env::remove_var("ANDROID_HOME") };
317    }
318
319    #[test]
320    fn explicit_cppwinrt_override_must_validate() {
321        unsafe { std::env::set_var("DAY_CPPWINRT", "/does/not/exist") };
322        assert_eq!(cppwinrt_include(), None); // bad override surfaces, not masked by fallbacks
323        unsafe { std::env::remove_var("DAY_CPPWINRT") };
324    }
325
326    #[test]
327    fn explicit_makensis_override_must_validate() {
328        unsafe { std::env::set_var("DAY_MAKENSIS", "/does/not/exist/makensis.exe") };
329        assert_eq!(makensis(), None); // same contract as the other overrides: never masked
330        unsafe { std::env::remove_var("DAY_MAKENSIS") };
331    }
332
333    /// The layouts `choco install nsis` can leave behind. Each is built for real under a temp
334    /// `ChocolateyInstall` so the probe is exercised rather than assumed — this is the lookup that
335    /// failed a release build after NSIS had actually been installed.
336    #[test]
337    fn makensis_found_in_chocolatey_layouts() {
338        let base = std::env::temp_dir().join(format!("day-choco-probe-{}", std::process::id()));
339        let shimmed = base.join("shim");
340        let unpacked = base.join("unpacked");
341        let nested = base.join("nested");
342        let _ = std::fs::remove_dir_all(&base);
343
344        // 1. the shim chocolatey drops in its bin dir
345        let shim_exe = shimmed.join("bin").join("makensis.exe");
346        std::fs::create_dir_all(shim_exe.parent().unwrap()).unwrap();
347        std::fs::write(&shim_exe, b"").unwrap();
348
349        // 2. unpacked under lib/nsis/tools/<versioned dir>/
350        let flat = unpacked
351            .join("lib/nsis/tools")
352            .join("nsis-3.10")
353            .join("makensis.exe");
354        std::fs::create_dir_all(flat.parent().unwrap()).unwrap();
355        std::fs::write(&flat, b"").unwrap();
356
357        // 3. …with the executable one level deeper, in Bin/
358        let deep = nested
359            .join("lib/nsis/tools")
360            .join("nsis-3.10")
361            .join("Bin")
362            .join("makensis.exe");
363        std::fs::create_dir_all(deep.parent().unwrap()).unwrap();
364        std::fs::write(&deep, b"").unwrap();
365
366        // The earlier probes must not answer first, or this proves nothing — and on a machine that
367        // really has NSIS in Program Files they would. Saved and put back below: PATH in particular
368        // is process-global, and leaving it empty would poison every test that runs after this one.
369        let (path, pf, pf86) = (
370            std::env::var_os("PATH"),
371            std::env::var_os("ProgramFiles"),
372            std::env::var_os("ProgramFiles(x86)"),
373        );
374        unsafe {
375            std::env::remove_var("DAY_MAKENSIS");
376            std::env::set_var("PATH", "");
377            std::env::set_var("ProgramFiles", base.join("no-such-pf"));
378            std::env::set_var("ProgramFiles(x86)", base.join("no-such-pf86"));
379        }
380
381        let found: Vec<_> = [(&shimmed, &shim_exe), (&unpacked, &flat), (&nested, &deep)]
382            .iter()
383            .map(|(root, want)| {
384                unsafe { std::env::set_var("ChocolateyInstall", root) };
385                (makensis(), (*want).clone())
386            })
387            .collect();
388
389        unsafe {
390            std::env::remove_var("ChocolateyInstall");
391            match path {
392                Some(v) => std::env::set_var("PATH", v),
393                None => std::env::remove_var("PATH"),
394            }
395            match pf {
396                Some(v) => std::env::set_var("ProgramFiles", v),
397                None => std::env::remove_var("ProgramFiles"),
398            }
399            match pf86 {
400                Some(v) => std::env::set_var("ProgramFiles(x86)", v),
401                None => std::env::remove_var("ProgramFiles(x86)"),
402            }
403        }
404        let _ = std::fs::remove_dir_all(&base);
405
406        // Asserted only after the environment is back, so a failure can't take the rest with it.
407        for (got, want) in found {
408            assert_eq!(got.as_ref(), Some(&want), "chocolatey layout {want:?}");
409        }
410    }
411}