use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Result, anyhow, bail};
use crate::loader::CrateDep;
fn cache_root() -> PathBuf {
if let Some(dir) = std::env::var_os("XDG_CACHE_HOME") {
PathBuf::from(dir).join("rustscript")
} else if let Some(home) = std::env::var_os("HOME") {
PathBuf::from(home).join(".cache/rustscript")
} else {
std::env::temp_dir().join("rustscript")
}
}
fn bin_cache() -> PathBuf {
cache_root().join("bin")
}
pub fn clean() -> Result<()> {
let root = cache_root();
if root.exists() {
std::fs::remove_dir_all(&root)?;
println!("cleared {}", root.display());
} else {
println!("nothing to clean");
}
Ok(())
}
pub fn check(
script_path: &Path,
files: &[(PathBuf, String)],
crate_deps: &[CrateDep],
) -> Result<()> {
if std::env::var_os("RUSTSCRIPT_SKIP_CHECK").is_some() {
return Ok(());
}
let hash = hash_files(files, crate_deps);
let project = cache_root().join(format!("{hash:016x}"));
let stamp = project.join(".checked");
if stamp.exists() {
return Ok(());
}
write_project(&project, files, crate_deps)?;
let output = Command::new("cargo")
.args(["check", "--quiet"])
.env("CARGO_TARGET_DIR", cache_root().join("target"))
.current_dir(&project)
.output();
let output = match output {
Ok(o) => o,
Err(e) => bail!("could not run cargo check: {e}"),
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!(
"{} is not valid Rust:\n{}",
script_path.display(),
stderr.trim_end()
);
}
std::fs::write(&stamp, "")?;
Ok(())
}
fn write_project(
project: &Path,
files: &[(PathBuf, String)],
crate_deps: &[CrateDep],
) -> Result<()> {
std::fs::create_dir_all(project)?;
std::fs::write(project.join("Cargo.toml"), manifest(crate_deps))?;
for (rel, source) in files {
let dst = project.join(rel);
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&dst, source)?;
}
Ok(())
}
pub fn build(
script_path: &Path,
files: &[(PathBuf, String)],
crate_deps: &[CrateDep],
) -> Result<PathBuf> {
let hash = hash_files(files, crate_deps);
let bin = bin_cache().join(format!("{hash:016x}{}", std::env::consts::EXE_SUFFIX));
if bin.exists() {
return Ok(bin);
}
let project = cache_root().join(format!("{hash:016x}"));
write_project(&project, files, crate_deps)?;
let target = cache_root().join("target");
eprintln!("rust: compiling {}", script_path.display());
let status = Command::new("cargo")
.args(["build"])
.env("CARGO_TARGET_DIR", &target)
.current_dir(&project)
.status();
let status = match status {
Ok(s) => s,
Err(e) => bail!("could not run cargo build: {e}"),
};
if !status.success() {
bail!("{} failed to compile", script_path.display());
}
let built = target
.join("debug")
.join(format!("script{}", std::env::consts::EXE_SUFFIX));
std::fs::create_dir_all(bin_cache())?;
let tmp = bin_cache().join(format!(".{hash:016x}.{}", std::process::id()));
std::fs::copy(&built, &tmp)
.map_err(|e| anyhow!("cannot copy built binary {}: {e}", built.display()))?;
match std::fs::rename(&tmp, &bin) {
Ok(()) => {}
Err(e) => {
if let Err(rm) = std::fs::remove_file(&tmp) {
eprintln!("rust: could not remove temp binary {}: {rm}", tmp.display());
}
if !bin.exists() {
return Err(anyhow!("cannot place binary {}: {e}", bin.display()));
}
}
}
Ok(bin)
}
const MANIFEST: &str = r#"[package]
name = "script"
version = "0.0.0"
edition = "2024"
[[bin]]
name = "script"
path = "main.rs"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
regex = "1"
which = "8"
rand = "0.10"
glob = "0.3"
chrono = "0.4"
dirs = "6"
toml = "1"
serde_yaml = "0.9"
colored = "3"
base64 = "0.22"
hex = "0.4"
ctrlc = "3"
tempfile = "3"
jsonwebtoken = { version = "10", features = ["rust_crypto"] }
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking", "cookies"], default-features = false }
[target."cfg(windows)".dependencies]
winreg = "0.56"
windows-service = "0.8"
wmi = "0.18"
"#;
fn manifest(crate_deps: &[CrateDep]) -> String {
let mut out = String::from(MANIFEST);
for dep in crate_deps {
let dir = dep.dir.to_string_lossy();
out.push_str(&format!("\n[dependencies.{}]\npath = {dir:?}\n", dep.name));
}
out.push_str("\n[workspace]\n");
out
}
fn hash_files(files: &[(PathBuf, String)], crate_deps: &[CrateDep]) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
for (rel, source) in files {
rel.hash(&mut hasher);
source.hash(&mut hasher);
}
for dep in crate_deps {
dep.name.hash(&mut hasher);
for (rel, source) in &dep.files {
rel.hash(&mut hasher);
source.hash(&mut hasher);
}
}
hasher.finish()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn graft_dep_is_all_target_not_windows_only() {
let dep = CrateDep {
name: "shared".to_string(),
dir: PathBuf::from("/tmp/shared"),
files: Vec::new(),
};
let text = manifest(&[dep]);
let value: toml::Value = toml::from_str(&text)
.unwrap_or_else(|e| panic!("manifest must be valid TOML: {e}\n{text}"));
let all_target = value
.get("dependencies")
.and_then(|d| d.get("shared"))
.is_some();
assert!(
all_target,
"shared must be an all-target dependency:\n{text}"
);
let windows_only = value
.get("target")
.and_then(|t| t.get("cfg(windows)"))
.and_then(|c| c.get("dependencies"))
.and_then(|d| d.get("shared"))
.is_some();
assert!(
!windows_only,
"shared must not be a Windows only dependency:\n{text}"
);
}
}