Skip to main content

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// wasm32 C compiler (web-dom's bundled-SQLite build)
251// ---------------------------------------------------------------------------
252
253/// How a web-dom build gets the C compiler for its `cc`-built dependencies (day-sqlite-worker's
254/// bundled SQLite), resolved by [`wasm_cc`].
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum WasmCc {
257    /// A cc-rs compiler variable is set. cc-rs will run this program whatever it is, so the
258    /// resolver reports it unprobed and callers must not override it.
259    Env(String),
260    /// Plain `clang` on PATH has the wasm32 backend — cc-rs's default works untouched.
261    PathClang,
262    /// A wasm-capable clang found outside PATH. Callers export it as
263    /// `CC_wasm32_unknown_unknown` on the cargo child process for cc-rs to use it.
264    Fallback(PathBuf),
265    /// No compiler with the backend anywhere; a `persistence` web build will fail in cc-rs.
266    Missing,
267}
268
269/// The C compiler a web-dom build's `cc`-built dependencies will use for
270/// `wasm32-unknown-unknown`.
271///
272/// Overrides: the `cc` crate's own variables, in its order — `CC_wasm32-unknown-unknown`,
273/// `CC_wasm32_unknown_unknown`, `TARGET_CC`, `CC`. With none set, plain `clang` is probed
274/// ([`emits_wasm32`] — Apple's Xcode clang has no wasm32 backend, the usual macOS miss), then
275/// installs that don't put clang on PATH: Homebrew LLVM kegs (keg-only, so `brew install llvm`
276/// alone is enough) and swift.org toolchains — a deliberately installed LLVM before one that
277/// rode in with Swift.
278pub fn wasm_cc() -> WasmCc {
279    for var in [
280        "CC_wasm32-unknown-unknown",
281        "CC_wasm32_unknown_unknown",
282        "TARGET_CC",
283        "CC",
284    ] {
285        if let Ok(v) = std::env::var(var)
286            && let Some(program) = v.split_whitespace().next()
287        {
288            return WasmCc::Env(program.to_string());
289        }
290    }
291    if emits_wasm32(Path::new("clang")) {
292        return WasmCc::PathClang;
293    }
294    let mut candidates: Vec<PathBuf> = ["/opt/homebrew", "/usr/local"]
295        .iter()
296        .map(|prefix| PathBuf::from(prefix).join("opt/llvm/bin/clang"))
297        .collect();
298    // swift.org toolchains each ship a full LLVM clang. `swift-latest` is the installer's
299    // "current" symlink; the directory scan behind it catches layouts without the symlink,
300    // newest release first where names sort by version.
301    let per_user = std::env::var("HOME")
302        .ok()
303        .map(|h| PathBuf::from(h).join("Library/Developer/Toolchains"));
304    let system = Some(PathBuf::from("/Library/Developer/Toolchains"));
305    for base in [per_user, system].into_iter().flatten() {
306        candidates.push(base.join("swift-latest.xctoolchain/usr/bin/clang"));
307        if let Ok(entries) = std::fs::read_dir(&base) {
308            let mut toolchains: Vec<PathBuf> = entries
309                .flatten()
310                .map(|e| e.path())
311                .filter(|p| p.extension().is_some_and(|e| e == "xctoolchain"))
312                .collect();
313            toolchains.sort();
314            toolchains.reverse();
315            candidates.extend(toolchains.into_iter().map(|p| p.join("usr/bin/clang")));
316        }
317    }
318    candidates
319        .into_iter()
320        .find(|c| c.is_file() && emits_wasm32(c))
321        .map(WasmCc::Fallback)
322        .unwrap_or(WasmCc::Missing)
323}
324
325/// Whether the clang-style driver at `cc` can emit wasm32 objects (`--print-targets` lists a
326/// `wasm32` row). Apple's Xcode clang is the notable no.
327pub fn emits_wasm32(cc: &Path) -> bool {
328    std::process::Command::new(cc)
329        .arg("--print-targets")
330        .output()
331        .ok()
332        .filter(|o| o.status.success())
333        .is_some_and(|o| {
334            String::from_utf8_lossy(&o.stdout)
335                .lines()
336                .any(|l| l.trim().starts_with("wasm32 "))
337        })
338}
339
340// ---------------------------------------------------------------------------
341// rustup
342// ---------------------------------------------------------------------------
343
344/// The rustup toolchain to use for cross-std builds (mobile targets need rustup's target std;
345/// a Homebrew/system rustc has none), as `(cargo_path, bin_dir)`. The bin dir is prepended to
346/// `PATH` so the toolchain's own `rustc` — not one earlier on `PATH` — is what cargo invokes.
347///
348/// Overrides: `RUSTUP_HOME` (standard; default `~/.rustup`). Among installed toolchains a
349/// `stable-*` one is preferred, then the lexicographically first — deterministic where the old
350/// first-directory-wins behavior depended on filesystem order.
351pub fn rustup_cargo() -> Result<(PathBuf, PathBuf), String> {
352    let rustup_home = std::env::var("RUSTUP_HOME")
353        .map(PathBuf::from)
354        .or_else(|_| {
355            std::env::var("HOME")
356                .map(|h| PathBuf::from(h).join(".rustup"))
357                .map_err(|e| e.to_string())
358        })?;
359    let toolchains = rustup_home.join("toolchains");
360    let mut entries: Vec<PathBuf> = std::fs::read_dir(&toolchains)
361        .map_err(|_| "no rustup toolchains (cross-std needs rustup, not Homebrew rust)")?
362        .flatten()
363        .map(|e| e.path())
364        .collect();
365    entries.sort();
366    let chosen = entries
367        .iter()
368        .find(|p| {
369            p.file_name()
370                .is_some_and(|n| n.to_string_lossy().starts_with("stable-"))
371        })
372        .or_else(|| entries.first())
373        .ok_or("empty rustup toolchains dir")?;
374    let bin = chosen.join("bin");
375    Ok((bin.join("cargo"), bin))
376}
377
378// ---------------------------------------------------------------------------
379
380fn on_path(tool: &str) -> Option<PathBuf> {
381    let path = std::env::var_os("PATH")?;
382    std::env::split_paths(&path)
383        .map(|d| d.join(tool))
384        .find(|p| p.is_file())
385}
386
387/// True when `dir` looks like a usable directory (exists and is a dir) — small helper for
388/// callers validating overrides.
389pub fn is_dir(dir: &Path) -> bool {
390    dir.is_dir()
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn kits_roots_honor_override_first() {
399        // SAFETY: test-local env mutation; tests touch distinct vars.
400        unsafe { std::env::set_var("DAY_WINDOWS_KITS_ROOT", "/custom/kits/10") };
401        let roots = windows_kits_roots();
402        assert_eq!(roots[0], PathBuf::from("/custom/kits/10"));
403        unsafe { std::env::remove_var("DAY_WINDOWS_KITS_ROOT") };
404    }
405
406    #[test]
407    fn android_sdk_honors_android_home() {
408        unsafe { std::env::set_var("ANDROID_HOME", "/custom/android") };
409        assert_eq!(android_sdk_dir(), PathBuf::from("/custom/android"));
410        unsafe { std::env::remove_var("ANDROID_HOME") };
411    }
412
413    #[test]
414    fn wasm_cc_honors_cc_variables_unprobed() {
415        // SAFETY: test-local env mutation; no other test touches the cc-rs variables.
416        unsafe { std::env::set_var("CC_wasm32-unknown-unknown", "/custom/clang --sysroot=/x") };
417        // Reported as-is (program only, flags dropped) even though the path doesn't exist:
418        // cc-rs will run whatever the variable says, so the resolver must not second-guess it.
419        assert_eq!(wasm_cc(), WasmCc::Env("/custom/clang".to_string()));
420        unsafe { std::env::remove_var("CC_wasm32-unknown-unknown") };
421    }
422
423    #[test]
424    fn explicit_cppwinrt_override_must_validate() {
425        unsafe { std::env::set_var("DAY_CPPWINRT", "/does/not/exist") };
426        assert_eq!(cppwinrt_include(), None); // bad override surfaces, not masked by fallbacks
427        unsafe { std::env::remove_var("DAY_CPPWINRT") };
428    }
429
430    #[test]
431    fn explicit_makensis_override_must_validate() {
432        unsafe { std::env::set_var("DAY_MAKENSIS", "/does/not/exist/makensis.exe") };
433        assert_eq!(makensis(), None); // same contract as the other overrides: never masked
434        unsafe { std::env::remove_var("DAY_MAKENSIS") };
435    }
436
437    /// The layouts `choco install nsis` can leave behind. Each is built for real under a temp
438    /// `ChocolateyInstall` so the probe is exercised rather than assumed — this is the lookup that
439    /// failed a release build after NSIS had actually been installed.
440    #[test]
441    fn makensis_found_in_chocolatey_layouts() {
442        let base = std::env::temp_dir().join(format!("day-choco-probe-{}", std::process::id()));
443        let shimmed = base.join("shim");
444        let unpacked = base.join("unpacked");
445        let nested = base.join("nested");
446        let _ = std::fs::remove_dir_all(&base);
447
448        // 1. the shim chocolatey drops in its bin dir
449        let shim_exe = shimmed.join("bin").join("makensis.exe");
450        std::fs::create_dir_all(shim_exe.parent().unwrap()).unwrap();
451        std::fs::write(&shim_exe, b"").unwrap();
452
453        // 2. unpacked under lib/nsis/tools/<versioned dir>/
454        let flat = unpacked
455            .join("lib/nsis/tools")
456            .join("nsis-3.10")
457            .join("makensis.exe");
458        std::fs::create_dir_all(flat.parent().unwrap()).unwrap();
459        std::fs::write(&flat, b"").unwrap();
460
461        // 3. …with the executable one level deeper, in Bin/
462        let deep = nested
463            .join("lib/nsis/tools")
464            .join("nsis-3.10")
465            .join("Bin")
466            .join("makensis.exe");
467        std::fs::create_dir_all(deep.parent().unwrap()).unwrap();
468        std::fs::write(&deep, b"").unwrap();
469
470        // The earlier probes must not answer first, or this proves nothing — and on a machine that
471        // really has NSIS in Program Files they would. Saved and put back below: PATH in particular
472        // is process-global, and leaving it empty would poison every test that runs after this one.
473        let (path, pf, pf86) = (
474            std::env::var_os("PATH"),
475            std::env::var_os("ProgramFiles"),
476            std::env::var_os("ProgramFiles(x86)"),
477        );
478        unsafe {
479            std::env::remove_var("DAY_MAKENSIS");
480            std::env::set_var("PATH", "");
481            std::env::set_var("ProgramFiles", base.join("no-such-pf"));
482            std::env::set_var("ProgramFiles(x86)", base.join("no-such-pf86"));
483        }
484
485        let found: Vec<_> = [(&shimmed, &shim_exe), (&unpacked, &flat), (&nested, &deep)]
486            .iter()
487            .map(|(root, want)| {
488                unsafe { std::env::set_var("ChocolateyInstall", root) };
489                (makensis(), (*want).clone())
490            })
491            .collect();
492
493        unsafe {
494            std::env::remove_var("ChocolateyInstall");
495            match path {
496                Some(v) => std::env::set_var("PATH", v),
497                None => std::env::remove_var("PATH"),
498            }
499            match pf {
500                Some(v) => std::env::set_var("ProgramFiles", v),
501                None => std::env::remove_var("ProgramFiles"),
502            }
503            match pf86 {
504                Some(v) => std::env::set_var("ProgramFiles(x86)", v),
505                None => std::env::remove_var("ProgramFiles(x86)"),
506            }
507        }
508        let _ = std::fs::remove_dir_all(&base);
509
510        // Asserted only after the environment is back, so a failure can't take the rest with it.
511        for (got, want) in found {
512            assert_eq!(got.as_ref(), Some(&want), "chocolatey layout {want:?}");
513        }
514    }
515}