use std::{
env, fs, io,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, anyhow};
use flate2::read::GzDecoder;
use reqwest::blocking::Client;
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT};
use serde::Deserialize;
use tar::Archive;
use zip::ZipArchive;
use crate::{
config::{
CompatibilityVerdict, GdsuiteConfig, InstalledContractsState, InstalledState,
InstalledToolState, Manifest, ReleaseManifest, ToolAcquireSpec,
},
process,
};
pub const GD_DIR: &str = ".gdsuite";
const GDTEST_HARNESS_PATH: &str = "addons/gdtest/gdtest_worker.gd";
const GDTEST_EDITOR_PLUGIN_DIR: &str = "addons/gdtest_editor";
pub const GDHEALTH_ARTIFACT_DIR: &str = "reports/code-health";
#[derive(Debug, Clone)]
pub struct ProjectContext {
pub root: PathBuf,
pub gdsuite_dir: PathBuf,
pub state_dir: PathBuf,
pub config_path: PathBuf,
pub gdtest_config_path: PathBuf,
pub gdhealth_config_path: PathBuf,
pub manifest_path: PathBuf,
pub installation_path: PathBuf,
pub versions_path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct Toolchain {
pub godot_exe: PathBuf,
pub python: PathBuf,
pub gdformat: PathBuf,
pub gdlint: PathBuf,
pub gdtest: PathBuf,
pub gdhealth: PathBuf,
pub gdtypecheck: PathBuf,
}
#[derive(Debug, Clone)]
pub struct InstallPlan {
pub gdtoolkit: ToolAcquireSpec,
pub gdtest: ToolAcquireSpec,
pub gdhealth: ToolAcquireSpec,
pub gdtypecheck: ToolAcquireSpec,
}
#[derive(Debug, Clone)]
pub struct InstallOutcome {
pub manifest: Manifest,
pub requested: InstallPlan,
}
#[derive(Debug, Clone)]
pub struct CompatibilityReport {
pub gdtoolkit: ToolCompatibility,
pub gdtest: ToolCompatibility,
pub gdhealth: ToolCompatibility,
pub gdtypecheck: ToolCompatibility,
}
#[derive(Debug, Clone)]
pub struct ToolCompatibility {
pub requested: String,
pub resolved_version: Option<String>,
pub verdict: CompatibilityVerdict,
pub reason: String,
pub extra: Vec<(String, String)>,
}
#[derive(Debug, Clone)]
struct ProbedToolchain {
gdtoolkit_version: Option<String>,
gdtest_version: Option<String>,
gdtest_harness_version: Option<String>,
gdhealth_version: Option<String>,
gdhealth_schema_version: Option<u32>,
gdtypecheck_version: Option<String>,
}
#[derive(Debug, Default, Clone)]
pub struct AddonInstallReport {
pub installed_files: usize,
pub skipped_modified: Vec<PathBuf>,
}
#[derive(Debug, Deserialize)]
struct GithubReleaseResponse {
assets: Vec<GithubReleaseAsset>,
}
#[derive(Debug, Deserialize)]
struct GithubReleaseAsset {
name: String,
url: String,
}
impl ProjectContext {
pub fn new(root: PathBuf) -> Self {
let gdsuite_dir = root.join(GD_DIR);
let state_dir = gdsuite_dir.join("state");
Self {
config_path: root.join("gdsuite.toml"),
gdtest_config_path: root.join("gdtest.toml"),
gdhealth_config_path: root.join("gdhealth.toml"),
manifest_path: state_dir.join("manifest.json"),
installation_path: state_dir.join("installation.json"),
versions_path: state_dir.join("versions.json"),
root,
gdsuite_dir,
state_dir,
}
}
pub fn ensure_dirs(&self) -> Result<()> {
fs::create_dir_all(self.gdsuite_dir.join("bin"))?;
fs::create_dir_all(self.gdsuite_dir.join("python"))?;
fs::create_dir_all(self.gdsuite_dir.join("tools"))?;
fs::create_dir_all(&self.state_dir)?;
Ok(())
}
}
const GITIGNORED: [&str; 4] = [
".godot/",
".gdsuite/",
"reports/gdsuite/",
"reports/code-health/",
];
pub fn ensure_gitignored(context: &ProjectContext) -> Result<()> {
let path = context.root.join(".gitignore");
let existing = fs::read_to_string(&path).unwrap_or_default();
let is_ignored = |entry: &str| {
existing
.lines()
.map(|line| line.trim().trim_start_matches('/'))
.any(|line| line == entry || line == entry.trim_end_matches('/'))
};
let missing: Vec<&str> = GITIGNORED
.into_iter()
.filter(|entry| !is_ignored(entry))
.collect();
if missing.is_empty() {
return Ok(());
}
let mut updated = existing.clone();
if !updated.is_empty() && !updated.ends_with('\n') {
updated.push('\n');
}
if !updated.is_empty() {
updated.push('\n');
}
updated.push_str("# Generated by gdsuite and its tools\n");
for entry in missing {
updated.push_str(entry);
updated.push('\n');
}
fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
}
pub fn remembered_godot(context: &ProjectContext) -> Option<PathBuf> {
if env::var_os("GODOT_EXE").is_some() {
return None;
}
load_manifest(context)
.ok()?
.godot_exe
.filter(|path| path.exists())
}
pub fn load_dotenv_if_present(context: &ProjectContext) -> Result<()> {
let path = context.root.join(".env");
if !path.exists() {
return Ok(());
}
let body =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
for (index, raw_line) in body.lines().enumerate() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("export ").unwrap_or(line).trim();
let Some((key, raw_value)) = line.split_once('=') else {
return Err(anyhow!(
"invalid .env entry at {}:{}",
path.display(),
index + 1
));
};
let key = key.trim();
if key.is_empty() {
return Err(anyhow!(
"invalid .env entry at {}:{}",
path.display(),
index + 1
));
}
if env::var_os(key).is_some() {
continue;
}
let value = parse_dotenv_value(raw_value.trim())
.ok_or_else(|| anyhow!("invalid .env value at {}:{}", path.display(), index + 1))?;
unsafe {
env::set_var(key, value);
}
}
Ok(())
}
fn parse_dotenv_value(raw: &str) -> Option<String> {
if raw.len() >= 2 && raw.starts_with('"') && raw.ends_with('"') {
let inner = &raw[1..raw.len() - 1];
return Some(
inner
.replace("\\n", "\n")
.replace("\\r", "\r")
.replace("\\t", "\t")
.replace("\\\"", "\"")
.replace("\\\\", "\\"),
);
}
if raw.len() >= 2 && raw.starts_with('\'') && raw.ends_with('\'') {
return Some(raw[1..raw.len() - 1].to_string());
}
let value = raw
.split_once('#')
.map(|(head, _)| head)
.unwrap_or(raw)
.trim()
.to_string();
Some(value)
}
pub fn load_config(context: &ProjectContext) -> Result<GdsuiteConfig> {
GdsuiteConfig::load(&context.config_path)
}
pub fn load_manifest(context: &ProjectContext) -> Result<Manifest> {
Manifest::load(&context.manifest_path)
}
pub fn save_manifest(context: &ProjectContext, manifest: &Manifest) -> Result<()> {
manifest.write(&context.manifest_path)
}
pub fn save_installation_state(context: &ProjectContext, state: &InstalledState) -> Result<()> {
state.write(&context.installation_path)
}
pub fn release_manifest() -> &'static ReleaseManifest {
ReleaseManifest::bundled()
}
pub fn install_plan(config: &GdsuiteConfig) -> InstallPlan {
let release = release_manifest();
InstallPlan {
gdtoolkit: if config.tools.gdtoolkit == default_gdtoolkit_requested() {
release.tools.gdtoolkit.acquire.clone()
} else {
ToolAcquireSpec::Pip {
package: "gdtoolkit".to_string(),
spec: config.tools.gdtoolkit.clone(),
}
},
gdtest: if config.tools.gdtest_source == default_gdtest_requested() {
release.tools.gdtest.acquire.clone()
} else {
parse_tool_source_spec(&config.tools.gdtest_source, "gdtest")
},
gdhealth: if config.tools.gdhealth_source == default_gdhealth_requested() {
release.tools.gdhealth.acquire.clone()
} else {
parse_tool_source_spec(&config.tools.gdhealth_source, "gdhealth")
},
gdtypecheck: if config.tools.gdtypecheck_source == default_gdtypecheck_requested() {
release.tools.gdtypecheck.acquire.clone()
} else {
parse_tool_source_spec(&config.tools.gdtypecheck_source, "gdtypecheck")
},
}
}
fn default_gdtoolkit_requested() -> String {
release_manifest()
.tools
.gdtoolkit
.acquire
.gdtoolkit_spec()
.expect("gdtoolkit release manifest must use pip")
.to_string()
}
fn default_gdtest_requested() -> String {
release_manifest().tools.gdtest.acquire.render_requested()
}
fn default_gdhealth_requested() -> String {
release_manifest().tools.gdhealth.acquire.render_requested()
}
fn default_gdtypecheck_requested() -> String {
release_manifest()
.tools
.gdtypecheck
.acquire
.render_requested()
}
pub fn resolve_godot(
context: &ProjectContext,
cli_override: Option<&Path>,
config: Option<&GdsuiteConfig>,
) -> Result<PathBuf> {
let mut candidates = Vec::new();
if let Some(path) = cli_override {
candidates.push(path.to_path_buf());
}
if let Some(path) = env::var_os("GODOT_EXE") {
candidates.push(PathBuf::from(path));
}
if let Some(config) = config
&& let Some(path) = &config.project.godot_exe
{
candidates.push(path.clone());
}
if let Ok(path) = which::which("godot") {
candidates.push(path);
}
if let Ok(path) = which::which("godot4") {
candidates.push(path);
}
candidates.push(PathBuf::from(r"C:\Program Files\Godot\Godot.exe"));
candidates.push(PathBuf::from(r"C:\Program Files\Godot_v4\Godot.exe"));
for candidate in candidates {
if let Some(path) = resolve_command_candidate(&candidate) {
return Ok(path);
}
for path in process::candidate_paths(candidate) {
if path.exists() {
return Ok(path);
}
}
}
Err(anyhow!(
"could not resolve Godot executable for {}",
context.root.display()
))
}
fn resolve_command_candidate(candidate: &Path) -> Option<PathBuf> {
if candidate.is_absolute() || candidate.parent().is_some() {
return None;
}
let command = candidate.to_str()?;
which::which(command).ok()
}
pub fn resolve_toolchain(
context: &ProjectContext,
manifest: Option<&Manifest>,
) -> Result<Toolchain> {
let manifest = manifest.cloned().unwrap_or_default();
Ok(Toolchain {
godot_exe: manifest
.godot_exe
.ok_or_else(|| anyhow!("missing Godot path in manifest"))?,
python: manifest
.python
.or_else(|| find_python_in_venv(context))
.ok_or_else(|| anyhow!("missing project Python in manifest"))?,
gdformat: manifest
.gdformat
.or_else(|| find_binary(context, &["gdformat"]))
.ok_or_else(|| anyhow!("missing gdformat in manifest"))?,
gdlint: manifest
.gdlint
.or_else(|| find_binary(context, &["gdlint"]))
.ok_or_else(|| anyhow!("missing gdlint in manifest"))?,
gdtest: manifest
.gdtest
.or_else(|| find_binary(context, &["gdtest"]))
.ok_or_else(|| anyhow!("missing gdtest in manifest"))?,
gdhealth: manifest
.gdhealth
.or_else(|| find_binary(context, &["gdhealth"]))
.ok_or_else(|| anyhow!("missing gdhealth in manifest"))?,
gdtypecheck: manifest
.gdtypecheck
.or_else(|| find_binary(context, &["gdtypecheck"]))
.ok_or_else(|| anyhow!("missing gdtypecheck in manifest"))?,
})
}
pub fn install_toolchain(
context: &ProjectContext,
config: &GdsuiteConfig,
godot_exe: &Path,
) -> Result<InstallOutcome> {
let requested = install_plan(config);
let python = ensure_venv(context)?;
install_gdtoolkit(&python, &requested.gdtoolkit, context)?;
let gdtest = install_tool(context, &requested.gdtest, "gdtest")?;
let gdhealth = install_tool(context, &requested.gdhealth, "gdhealth")?;
let gdtypecheck = install_tool(context, &requested.gdtypecheck, "gdtypecheck")?;
let gdformat =
find_binary(context, &["gdformat"]).ok_or_else(|| anyhow!("gdformat install missing"))?;
let gdlint =
find_binary(context, &["gdlint"]).ok_or_else(|| anyhow!("gdlint install missing"))?;
Ok(InstallOutcome {
manifest: Manifest {
godot_exe: Some(godot_exe.to_path_buf()),
python: Some(python),
gdformat: Some(gdformat),
gdlint: Some(gdlint),
gdtest: Some(gdtest),
gdhealth: Some(gdhealth),
gdtypecheck: Some(gdtypecheck),
},
requested,
})
}
pub fn verify_toolchain(context: &ProjectContext, toolchain: &Toolchain) -> Result<()> {
process::run_checked(
&toolchain.godot_exe,
&["--version".to_string()],
&context.root,
)?;
process::run_checked(&toolchain.python, &["--version".to_string()], &context.root)?;
process::run_checked(&toolchain.gdformat, &["--help".to_string()], &context.root)?;
process::run_checked(&toolchain.gdlint, &["--help".to_string()], &context.root)?;
process::run_checked(&toolchain.gdtest, &["--version".to_string()], &context.root)?;
process::run_checked(&toolchain.gdhealth, &["--help".to_string()], &context.root)?;
process::run_checked(
&toolchain.gdtypecheck,
&["--version".to_string()],
&context.root,
)?;
Ok(())
}
pub fn ensure_gdtest_class_cache(
context: &ProjectContext,
_config: &GdsuiteConfig,
godot_exe: &Path,
addon_report: &AddonInstallReport,
) -> Result<()> {
let runtime_dir = harness_parent_dir(GDTEST_HARNESS_PATH);
let runtime_res_dir = res_dir_from_rel_path(&runtime_dir);
let gdt_res_path = join_res_path(&runtime_res_dir, "gdt.gd");
let cache_path = context
.root
.join(".godot")
.join("global_script_class_cache.cfg");
let cache_has_gdt = fs::read_to_string(&cache_path)
.map(|body| body.contains(&format!("\"path\": \"{gdt_res_path}\"")))
.unwrap_or(false);
if addon_report.installed_files == 0 && cache_has_gdt {
return Ok(());
}
let args = vec![
"--headless".to_string(),
"--path".to_string(),
context.root.display().to_string(),
"--import".to_string(),
];
process::run_checked(godot_exe, &args, &context.root).with_context(|| {
format!(
"failed to build Godot class cache for gdtest runtime at {}",
gdt_res_path
)
})?;
Ok(())
}
pub fn find_binary(context: &ProjectContext, names: &[&str]) -> Option<PathBuf> {
let roots = [
context.gdsuite_dir.join("python").join("Scripts"),
context.gdsuite_dir.join("python").join("bin"),
context.gdsuite_dir.join("tools").join("gdtest").join("bin"),
context
.gdsuite_dir
.join("tools")
.join("gdhealth")
.join("bin"),
context
.gdsuite_dir
.join("tools")
.join("gdtypecheck")
.join("bin"),
context.gdsuite_dir.join("bin"),
];
for root in roots {
for name in names {
for candidate in process::candidate_paths(root.join(name)) {
if candidate.exists() {
return Some(candidate);
}
}
}
}
None
}
fn ensure_venv(context: &ProjectContext) -> Result<PathBuf> {
if let Some(path) = find_python_in_venv(context) {
return Ok(path);
}
let python = which::which("python")
.or_else(|_| which::which("python3"))
.or_else(|_| which::which("py"))
.context("python is required for setup")?;
process::run_checked(
&python,
&[
"-m".to_string(),
"venv".to_string(),
context.gdsuite_dir.join("python").display().to_string(),
],
&context.root,
)?;
find_python_in_venv(context).ok_or_else(|| anyhow!("venv bootstrap did not produce python"))
}
fn install_gdtoolkit(
python: &Path,
requested: &ToolAcquireSpec,
context: &ProjectContext,
) -> Result<()> {
let ToolAcquireSpec::Pip { package, spec } = requested else {
return Err(anyhow!("gdtoolkit must use a pip acquisition spec"));
};
let package = format!("{package}{spec}");
let args = vec![
"-m".to_string(),
"pip".to_string(),
"install".to_string(),
package,
];
process::run_checked(python, &args, &context.root)?;
Ok(())
}
fn install_tool(
context: &ProjectContext,
requested: &ToolAcquireSpec,
name: &str,
) -> Result<PathBuf> {
let cargo = which::which("cargo").context("cargo is required for setup")?;
let root = context.gdsuite_dir.join("tools").join(name);
fs::create_dir_all(&root)
.with_context(|| format!("failed to create tool root {}", root.display()))?;
match requested {
ToolAcquireSpec::Path { path } => {
let package_root = resolve_package_root(&context.root.join(path), name)?;
let args = vec![
"install".to_string(),
"--path".to_string(),
package_root.display().to_string(),
"--root".to_string(),
root.display().to_string(),
];
process::run_checked(&cargo, &args, &context.root)?;
}
ToolAcquireSpec::Crate {
crate_name,
version,
} => {
let source_root = tool_source_cache_dir(context, name);
reset_dir(&source_root)?;
let archive_path = download_file(
&crate_download_url(crate_name, version),
&tool_download_cache_dir(context, name)
.join(format!("{crate_name}-{version}.crate")),
)?;
unpack_archive(&archive_path, &source_root)?;
let args = vec![
"install".to_string(),
crate_name.clone(),
"--version".to_string(),
version.clone(),
"--root".to_string(),
root.display().to_string(),
];
process::run_checked(&cargo, &args, &context.root)?;
}
ToolAcquireSpec::Git { url, rev, subdir } => {
let source_root = tool_source_cache_dir(context, name);
if source_root.exists() {
fs::remove_dir_all(&source_root)
.with_context(|| format!("failed to remove {}", source_root.display()))?;
}
clone_git_source(url, rev, &source_root)?;
let package_root = subdir
.as_ref()
.map(|value| source_root.join(value))
.unwrap_or_else(|| source_root.clone());
if !package_root.exists() {
return Err(anyhow!(
"git source subdir does not exist for {}: {}",
name,
package_root.display()
));
}
let package_root = resolve_package_root(&package_root, name)?;
let args = vec![
"install".to_string(),
"--path".to_string(),
package_root.display().to_string(),
"--root".to_string(),
root.display().to_string(),
];
process::run_checked(&cargo, &args, &context.root)?;
}
ToolAcquireSpec::GithubRelease { url } => {
let download_name = file_name_from_url(url).unwrap_or_else(|| name.to_string());
let archive_path = download_file(
url,
&tool_download_cache_dir(context, name).join(download_name),
)?;
if is_archive_path(&archive_path) {
install_binary_archive(&archive_path, &root, name)?;
} else {
install_downloaded_binary(&archive_path, &root, name)?;
}
}
ToolAcquireSpec::GithubReleaseRepo { owner, repo, tag } => {
let (target, extension) = current_release_asset_target()?;
let asset_name = format!("{name}-{tag}-{target}.{extension}");
let archive_path = if github_token().is_some() {
download_github_release_asset(
owner,
repo,
tag,
&asset_name,
&tool_download_cache_dir(context, name).join(&asset_name),
)?
} else {
let url = github_release_asset_url(owner, repo, tag, name)?;
let download_name = file_name_from_url(&url).unwrap_or_else(|| name.to_string());
download_file(
&url,
&tool_download_cache_dir(context, name).join(download_name),
)?
};
install_binary_archive(&archive_path, &root, name)?;
}
ToolAcquireSpec::Pip { .. } => {
return Err(anyhow!("{name} cannot use a pip acquisition spec"));
}
}
for candidate in process::candidate_paths(root.join("bin").join(name)) {
if candidate.exists() {
return Ok(candidate);
}
}
Err(anyhow!("cargo install did not produce {}", name))
}
fn resolve_package_root(source_root: &Path, name: &str) -> Result<PathBuf> {
let manifest_path = source_root.join("Cargo.toml");
if !manifest_path.exists() {
return Ok(source_root.to_path_buf());
}
let text = fs::read_to_string(&manifest_path)
.with_context(|| format!("failed to read {}", manifest_path.display()))?;
let manifest: toml::Value = toml::from_str(&text)
.with_context(|| format!("failed to parse {}", manifest_path.display()))?;
if manifest.get("package").is_some() {
return Ok(source_root.to_path_buf());
}
let members = manifest
.get("workspace")
.and_then(|w| w.get("members"))
.and_then(|m| m.as_array())
.ok_or_else(|| anyhow!("workspace at {} has no members", source_root.display()))?;
for member in members {
let member_path = member
.as_str()
.ok_or_else(|| anyhow!("invalid workspace member"))?;
let candidate = source_root.join(member_path);
let member_manifest = candidate.join("Cargo.toml");
if !member_manifest.exists() {
continue;
}
let member_text = fs::read_to_string(&member_manifest)?;
let member_toml: toml::Value = toml::from_str(&member_text)?;
let package_name = member_toml
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str());
if package_name == Some(name) {
return Ok(candidate);
}
}
Err(anyhow!(
"could not find package '{}' in workspace at {}",
name,
source_root.display()
))
}
fn parse_tool_source_spec(source: &str, default_name: &str) -> ToolAcquireSpec {
if let Some(rest) = source.strip_prefix("crate:")
&& let Some((name, version)) = rest.split_once('@')
&& !name.is_empty()
&& !version.is_empty()
{
return ToolAcquireSpec::Crate {
crate_name: name.to_string(),
version: version.to_string(),
};
}
if let Some(url) = source.strip_prefix("github:")
&& !url.is_empty()
{
if !url.starts_with("http://")
&& !url.starts_with("https://")
&& let Some((repo_path, tag)) = url.rsplit_once('@')
&& let Some((owner, repo)) = repo_path.split_once('/')
&& !owner.is_empty()
&& !repo.is_empty()
&& !tag.is_empty()
{
return ToolAcquireSpec::GithubReleaseRepo {
owner: owner.to_string(),
repo: repo.to_string(),
tag: tag.to_string(),
};
}
return ToolAcquireSpec::GithubRelease {
url: url.to_string(),
};
}
if let Some(rest) = source.strip_prefix("git:") {
let (url, query) = rest.split_once('?').unwrap_or((rest, ""));
let mut rev = None;
let mut subdir = None;
for pair in query.split('&').filter(|segment| !segment.is_empty()) {
if let Some((key, value)) = pair.split_once('=') {
match key {
"rev" => rev = Some(value.to_string()),
"subdir" => subdir = Some(value.to_string()),
_ => {}
}
}
}
if !url.is_empty()
&& let Some(rev) = rev
{
return ToolAcquireSpec::Git {
url: url.to_string(),
rev,
subdir,
};
}
}
if let Some(path) = source.strip_prefix("path:")
&& !path.is_empty()
{
return ToolAcquireSpec::Path {
path: path.to_string(),
};
}
if source.contains("://") {
return ToolAcquireSpec::Git {
url: source.to_string(),
rev: "HEAD".to_string(),
subdir: Some(default_name.to_string()),
};
}
ToolAcquireSpec::Path {
path: source.to_string(),
}
}
fn resolved_source_root(context: &ProjectContext, source: &str, name: &str) -> Result<PathBuf> {
match parse_tool_source_spec(source, name) {
ToolAcquireSpec::Path { path } => Ok(context.root.join(path)),
ToolAcquireSpec::Crate { .. }
| ToolAcquireSpec::GithubRelease { .. }
| ToolAcquireSpec::GithubReleaseRepo { .. }
| ToolAcquireSpec::Git { .. } => {
let source_root = tool_source_cache_dir(context, name);
if !source_root.exists() {
return Err(anyhow!(
"tool source cache missing for {}; run `gdsuite setup` to install the toolchain",
name
));
}
normalize_extracted_root(&source_root)
}
ToolAcquireSpec::Pip { .. } => Err(anyhow!("pip source is not valid for {}", name)),
}
}
fn tool_source_cache_dir(context: &ProjectContext, name: &str) -> PathBuf {
context.gdsuite_dir.join("tools").join(name).join("source")
}
fn tool_download_cache_dir(context: &ProjectContext, name: &str) -> PathBuf {
context
.gdsuite_dir
.join("tools")
.join(name)
.join("downloads")
}
fn crate_download_url(name: &str, version: &str) -> String {
if let Ok(base) = env::var("GDSUITE_CRATES_DOWNLOAD_BASE_URL") {
let base = base.trim_end_matches('/');
return format!("{base}/{name}/{version}/download");
}
format!("https://crates.io/api/v1/crates/{name}/{version}/download")
}
fn download_file(url: &str, destination: &Path) -> Result<PathBuf> {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create download dir {}", parent.display()))?;
}
let client = http_client()?;
let mut response = client
.get(url)
.send()
.with_context(|| format!("failed to download {url}"))?
.error_for_status()
.with_context(|| format!("failed to download {url}"))?;
let mut file = fs::File::create(destination)
.with_context(|| format!("failed to create {}", destination.display()))?;
io::copy(&mut response, &mut file)
.with_context(|| format!("failed to write {}", destination.display()))?;
Ok(destination.to_path_buf())
}
fn download_github_release_asset(
owner: &str,
repo: &str,
tag: &str,
asset_name: &str,
destination: &Path,
) -> Result<PathBuf> {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create download dir {}", parent.display()))?;
}
let client = http_client()?;
let release_url = format!(
"{}/repos/{owner}/{repo}/releases/tags/{tag}",
github_api_base_url()
);
let release_body = client
.get(&release_url)
.header("Accept", "application/vnd.github+json")
.send()
.with_context(|| format!("failed to query GitHub release metadata at {release_url}"))?
.error_for_status()
.with_context(|| format!("failed to query GitHub release metadata at {release_url}"))?
.text()
.with_context(|| format!("failed to read GitHub release metadata from {release_url}"))?;
let release: GithubReleaseResponse = serde_json::from_str(&release_body)
.with_context(|| format!("failed to parse GitHub release metadata from {release_url}"))?;
let asset = release
.assets
.into_iter()
.find(|asset| asset.name == asset_name)
.ok_or_else(|| {
anyhow!(
"GitHub release {owner}/{repo}@{tag} did not contain expected asset {asset_name}"
)
})?;
let mut response = client
.get(&asset.url)
.header("Accept", "application/octet-stream")
.send()
.with_context(|| format!("failed to download GitHub release asset {}", asset.name))?
.error_for_status()
.with_context(|| format!("failed to download GitHub release asset {}", asset.name))?;
let mut file = fs::File::create(destination)
.with_context(|| format!("failed to create {}", destination.display()))?;
io::copy(&mut response, &mut file)
.with_context(|| format!("failed to write {}", destination.display()))?;
Ok(destination.to_path_buf())
}
fn http_client() -> Result<Client> {
let mut headers = HeaderMap::new();
headers.insert(
USER_AGENT,
HeaderValue::from_static(concat!(
env!("CARGO_PKG_NAME"),
"/",
env!("CARGO_PKG_VERSION")
)),
);
if let Some(token) = github_token() {
let value = HeaderValue::from_str(&format!("Bearer {token}"))
.context("GDSUITE_GITHUB_TOKEN contains invalid header characters")?;
headers.insert(AUTHORIZATION, value);
}
Client::builder()
.default_headers(headers)
.redirect(reqwest::redirect::Policy::limited(10))
.build()
.context("failed to initialize HTTP client")
}
fn github_token() -> Option<String> {
let token = env::var("GDSUITE_GITHUB_TOKEN").ok()?;
let token = token.trim();
if token.is_empty() {
None
} else {
Some(token.to_string())
}
}
fn clone_git_source(url: &str, rev: &str, destination: &Path) -> Result<()> {
let git = which::which("git").context("git is required for git-based setup sources")?;
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let clone_args = vec![
"clone".to_string(),
url.to_string(),
command_path(destination),
];
process::run_checked(&git, &clone_args, Path::new("."))?;
let fetch_args = vec![
"-C".to_string(),
command_path(destination),
"fetch".to_string(),
"--depth".to_string(),
"1".to_string(),
"origin".to_string(),
rev.to_string(),
];
process::run_checked(&git, &fetch_args, Path::new("."))?;
let checkout_args = vec![
"-C".to_string(),
command_path(destination),
"checkout".to_string(),
"FETCH_HEAD".to_string(),
];
process::run_checked(&git, &checkout_args, Path::new("."))?;
Ok(())
}
fn install_binary_archive(archive_path: &Path, root: &Path, name: &str) -> Result<()> {
let source_root = root.join("source");
reset_dir(&source_root)?;
unpack_archive(archive_path, &source_root)?;
let extracted_root = normalize_extracted_root(&source_root)?;
if extracted_root.join("Cargo.toml").exists() {
let package_root = resolve_package_root(&extracted_root, name)?;
let cargo = which::which("cargo").context("cargo is required for setup")?;
let args = vec![
"install".to_string(),
"--path".to_string(),
package_root.display().to_string(),
"--root".to_string(),
root.display().to_string(),
];
process::run_checked(&cargo, &args, root.parent().unwrap_or(root))?;
return Ok(());
}
let binary = find_extracted_binary(&extracted_root, name)?;
install_binary_at_path(&binary, root, name)
}
fn install_downloaded_binary(path: &Path, root: &Path, name: &str) -> Result<()> {
install_binary_at_path(path, root, name)
}
fn install_binary_at_path(path: &Path, root: &Path, name: &str) -> Result<()> {
let extension = path
.extension()
.and_then(|value| value.to_str())
.map(|value| format!(".{value}"))
.unwrap_or_default();
let target = root.join("bin").join(format!("{name}{extension}"));
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
fs::copy(path, &target)
.with_context(|| format!("failed to copy {} to {}", path.display(), target.display()))?;
make_executable(&target)?;
Ok(())
}
fn unpack_archive(archive_path: &Path, destination: &Path) -> Result<()> {
fs::create_dir_all(destination)
.with_context(|| format!("failed to create {}", destination.display()))?;
let file = fs::File::open(archive_path)
.with_context(|| format!("failed to open {}", archive_path.display()))?;
let file_name = archive_path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default();
if file_name.ends_with(".zip") {
let mut archive = ZipArchive::new(file)
.with_context(|| format!("failed to read zip archive {}", archive_path.display()))?;
archive
.extract(destination)
.with_context(|| format!("failed to extract {}", archive_path.display()))?;
return Ok(());
}
if file_name.ends_with(".tar.gz")
|| file_name.ends_with(".tgz")
|| file_name.ends_with(".crate")
{
let reader = GzDecoder::new(file);
let mut archive = Archive::new(reader);
archive
.unpack(destination)
.with_context(|| format!("failed to extract {}", archive_path.display()))?;
return Ok(());
}
Err(anyhow!(
"unsupported archive format for {}",
archive_path.display()
))
}
fn normalize_extracted_root(source_root: &Path) -> Result<PathBuf> {
let children = fs::read_dir(source_root)
.with_context(|| format!("failed to read {}", source_root.display()))?
.filter_map(|entry| entry.ok())
.collect::<Vec<_>>();
if children.len() == 1 {
let child = children[0].path();
if child.is_dir() {
return Ok(child);
}
}
Ok(source_root.to_path_buf())
}
fn find_extracted_binary(root: &Path, name: &str) -> Result<PathBuf> {
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in
fs::read_dir(&dir).with_context(|| format!("failed to read {}", dir.display()))?
{
let entry = entry?;
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let stem = path.file_stem().and_then(|value| value.to_str());
let extension = path.extension().and_then(|value| value.to_str());
let is_binary_name =
matches!(extension, Some("exe" | "cmd" | "bat")) || extension.is_none();
if stem == Some(name) && is_binary_name {
return Ok(path);
}
}
}
Err(anyhow!(
"archive did not contain an executable or cargo package for {}",
name
))
}
fn is_archive_path(path: &Path) -> bool {
let name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default();
name.ends_with(".zip")
|| name.ends_with(".tar.gz")
|| name.ends_with(".tgz")
|| name.ends_with(".crate")
}
fn file_name_from_url(url: &str) -> Option<String> {
let clean = url.split('?').next().unwrap_or(url).trim_end_matches('/');
clean.rsplit('/').next().map(ToString::to_string)
}
fn github_release_asset_url(
owner: &str,
repo: &str,
tag: &str,
binary_name: &str,
) -> Result<String> {
let (target, extension) = current_release_asset_target()?;
let base = github_release_base_url();
Ok(format!(
"{base}/{owner}/{repo}/releases/download/{tag}/{binary_name}-{tag}-{target}.{extension}"
))
}
fn github_release_base_url() -> String {
env::var("GDSUITE_GITHUB_RELEASE_BASE_URL")
.unwrap_or_else(|_| "https://github.com".to_string())
.trim_end_matches('/')
.to_string()
}
fn github_api_base_url() -> String {
env::var("GDSUITE_GITHUB_API_BASE_URL")
.unwrap_or_else(|_| "https://api.github.com".to_string())
.trim_end_matches('/')
.to_string()
}
fn current_release_asset_target() -> Result<(&'static str, &'static str)> {
match (env::consts::OS, env::consts::ARCH) {
("windows", "x86_64") => Ok(("x86_64-pc-windows-msvc", "zip")),
("macos", "x86_64") => Ok(("x86_64-apple-darwin", "tar.gz")),
("macos", "aarch64") => Ok(("aarch64-apple-darwin", "tar.gz")),
("linux", "x86_64") => Ok(("x86_64-unknown-linux-musl", "tar.gz")),
(os, arch) => Err(anyhow!(
"unsupported platform for GitHub release installs: {arch}-{os}"
)),
}
}
fn command_path(path: &Path) -> String {
#[cfg(windows)]
{
let text = path.display().to_string();
text.strip_prefix(r"\\?\").unwrap_or(&text).to_string()
}
#[cfg(not(windows))]
{
path.display().to_string()
}
}
fn reset_dir(path: &Path) -> Result<()> {
if path.exists() {
fs::remove_dir_all(path).with_context(|| format!("failed to remove {}", path.display()))?;
}
fs::create_dir_all(path).with_context(|| format!("failed to create {}", path.display()))?;
Ok(())
}
fn make_executable(path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(path)?.permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions)?;
}
#[cfg(not(unix))]
let _ = path;
Ok(())
}
pub fn find_python_in_venv(context: &ProjectContext) -> Option<PathBuf> {
let bases = [
context
.gdsuite_dir
.join("python")
.join("Scripts")
.join("python"),
context
.gdsuite_dir
.join("python")
.join("bin")
.join("python"),
];
for base in bases {
for path in process::candidate_paths(base) {
if path.exists() {
return Some(path);
}
}
}
None
}
pub fn probe_installation_state(
context: &ProjectContext,
config: &GdsuiteConfig,
manifest: &Manifest,
requested: &InstallPlan,
) -> Result<InstalledState> {
let toolchain = resolve_toolchain(context, Some(manifest))?;
let probed = probe_toolchain(context, config, &toolchain)?;
Ok(InstalledState {
suite_version: release_manifest().suite.version.clone(),
manifest_version: release_manifest().suite.manifest_version,
tools: crate::config::InstalledToolsState {
gdtoolkit: InstalledToolState {
requested: requested.gdtoolkit.clone(),
resolved_version: probed.gdtoolkit_version.clone(),
},
gdtest: InstalledToolState {
requested: requested.gdtest.clone(),
resolved_version: probed.gdtest_version.clone(),
},
gdhealth: InstalledToolState {
requested: requested.gdhealth.clone(),
resolved_version: probed.gdhealth_version.clone(),
},
gdtypecheck: InstalledToolState {
requested: requested.gdtypecheck.clone(),
resolved_version: probed.gdtypecheck_version.clone(),
},
},
contracts: InstalledContractsState {
gdtest_harness_version: probed.gdtest_harness_version,
gdhealth_schema_version: probed.gdhealth_schema_version,
},
})
}
pub fn compatibility_report(state: &InstalledState) -> CompatibilityReport {
let release = release_manifest();
let gdtoolkit = match &state.tools.gdtoolkit.resolved_version {
Some(version) => evaluate_versioned_tool(
"gdtoolkit",
&state.tools.gdtoolkit.requested,
version,
&release.tools.gdtoolkit.acquire,
&release.tools.gdtoolkit.tested,
Vec::new(),
),
None => ToolCompatibility {
requested: state.tools.gdtoolkit.requested.render_requested(),
resolved_version: None,
verdict: CompatibilityVerdict::Error,
reason: "gdtoolkit is not installed in the project venv".to_string(),
extra: Vec::new(),
},
};
let gdtest = match (
&state.tools.gdtest.resolved_version,
&state.contracts.gdtest_harness_version,
) {
(Some(version), Some(harness))
if normalize_version(version) == normalize_version(harness) =>
{
let mut report = evaluate_versioned_tool(
"gdtest",
&state.tools.gdtest.requested,
version,
&release.tools.gdtest.acquire,
&release.tools.gdtest.tested,
vec![("gdtest_harness".to_string(), harness.clone())],
);
if normalize_version(harness)
!= normalize_spec(&release.contracts.gdtest.required_harness)
{
report.verdict = CompatibilityVerdict::Warning;
report.reason = format!(
"gdtest harness {} is outside the suite-tested harness {}",
harness,
normalize_spec(&release.contracts.gdtest.required_harness)
);
}
report
}
(Some(version), Some(harness)) => ToolCompatibility {
requested: state.tools.gdtest.requested.render_requested(),
resolved_version: Some(version.clone()),
verdict: CompatibilityVerdict::Error,
reason: format!("gdtest CLI {version} is incompatible with harness {harness}"),
extra: vec![("gdtest_harness".to_string(), harness.clone())],
},
(Some(version), None) => ToolCompatibility {
requested: state.tools.gdtest.requested.render_requested(),
resolved_version: Some(version.clone()),
verdict: CompatibilityVerdict::Error,
reason: "gdtest harness version could not be determined".to_string(),
extra: Vec::new(),
},
(None, _) => ToolCompatibility {
requested: state.tools.gdtest.requested.render_requested(),
resolved_version: None,
verdict: CompatibilityVerdict::Error,
reason: "gdtest version probe failed".to_string(),
extra: Vec::new(),
},
};
let gdhealth = match (
&state.tools.gdhealth.resolved_version,
state.contracts.gdhealth_schema_version,
) {
(Some(version), Some(schema))
if schema == release.contracts.gdhealth.required_schema_version =>
{
evaluate_versioned_tool(
"gdhealth",
&state.tools.gdhealth.requested,
version,
&release.tools.gdhealth.acquire,
&release.tools.gdhealth.tested,
vec![("gdhealth_schema".to_string(), schema.to_string())],
)
}
(Some(version), Some(schema)) => ToolCompatibility {
requested: state.tools.gdhealth.requested.render_requested(),
resolved_version: Some(version.clone()),
verdict: CompatibilityVerdict::Error,
reason: format!(
"gdhealth schema {} is incompatible with required schema {}",
schema, release.contracts.gdhealth.required_schema_version
),
extra: vec![("gdhealth_schema".to_string(), schema.to_string())],
},
(Some(version), None) => ToolCompatibility {
requested: state.tools.gdhealth.requested.render_requested(),
resolved_version: Some(version.clone()),
verdict: CompatibilityVerdict::Error,
reason: "gdhealth schema version could not be determined".to_string(),
extra: Vec::new(),
},
(None, _) => ToolCompatibility {
requested: state.tools.gdhealth.requested.render_requested(),
resolved_version: None,
verdict: CompatibilityVerdict::Error,
reason: "gdhealth version probe failed".to_string(),
extra: Vec::new(),
},
};
let gdtypecheck = match &state.tools.gdtypecheck.resolved_version {
Some(version) => evaluate_versioned_tool(
"gdtypecheck",
&state.tools.gdtypecheck.requested,
version,
&release.tools.gdtypecheck.acquire,
&release.tools.gdtypecheck.tested,
Vec::new(),
),
None => ToolCompatibility {
requested: state.tools.gdtypecheck.requested.render_requested(),
resolved_version: None,
verdict: CompatibilityVerdict::Error,
reason: "gdtypecheck version probe failed".to_string(),
extra: Vec::new(),
},
};
CompatibilityReport {
gdtoolkit,
gdtest,
gdhealth,
gdtypecheck,
}
}
pub fn report_status(report: &CompatibilityReport) -> CompatibilityVerdict {
let verdicts = [
&report.gdtoolkit.verdict,
&report.gdtest.verdict,
&report.gdhealth.verdict,
&report.gdtypecheck.verdict,
];
if verdicts
.iter()
.any(|verdict| **verdict == CompatibilityVerdict::Error)
{
CompatibilityVerdict::Error
} else if verdicts
.iter()
.any(|verdict| **verdict == CompatibilityVerdict::Warning)
{
CompatibilityVerdict::Warning
} else {
CompatibilityVerdict::Ok
}
}
fn evaluate_versioned_tool(
tool_name: &'static str,
requested: &ToolAcquireSpec,
version: &str,
release_requested: &ToolAcquireSpec,
tested_spec: &str,
extra: Vec<(String, String)>,
) -> ToolCompatibility {
let requested_rendered = requested.render_requested();
let tested_version = normalize_spec(tested_spec);
let is_override = requested_rendered != release_requested.render_requested();
let version_matches = normalize_version(version) == tested_version;
let (verdict, reason) = if is_override {
(
CompatibilityVerdict::Warning,
format!(
"{tool_name} was installed from `{}` and is outside the suite-tested set",
requested_rendered
),
)
} else if !version_matches {
(
CompatibilityVerdict::Warning,
format!(
"{tool_name} {} is outside the suite-tested version {}",
version, tested_version
),
)
} else {
(
CompatibilityVerdict::Ok,
format!("{tool_name} matches the suite-tested contract"),
)
};
ToolCompatibility {
requested: requested_rendered,
resolved_version: Some(version.to_string()),
verdict,
reason,
extra,
}
}
fn probe_toolchain(
context: &ProjectContext,
config: &GdsuiteConfig,
toolchain: &Toolchain,
) -> Result<ProbedToolchain> {
Ok(ProbedToolchain {
gdtoolkit_version: probe_pip_package_version(&toolchain.python, "gdtoolkit", context),
gdtest_version: probe_command_version(&toolchain.gdtest, &["--version"], context),
gdtest_harness_version: probe_gdtest_harness_version(context, config),
gdhealth_version: probe_command_version(&toolchain.gdhealth, &["--version"], context),
gdhealth_schema_version: probe_gdhealth_schema_version(&toolchain.gdhealth, context),
gdtypecheck_version: probe_command_version(&toolchain.gdtypecheck, &["--version"], context),
})
}
fn probe_pip_package_version(
python: &Path,
package: &str,
context: &ProjectContext,
) -> Option<String> {
let args = vec![
"-m".to_string(),
"pip".to_string(),
"show".to_string(),
package.to_string(),
];
let output = process::run(python, &args, &context.root).ok()?;
if !output.status.success() {
return None;
}
output.stdout.lines().find_map(|line| {
line.strip_prefix("Version:")
.map(|value| value.trim().to_string())
})
}
fn probe_command_version(path: &Path, args: &[&str], context: &ProjectContext) -> Option<String> {
let args = args
.iter()
.map(|value| value.to_string())
.collect::<Vec<_>>();
let output = process::run(path, &args, &context.root).ok()?;
if !output.status.success() {
return None;
}
output
.stdout
.lines()
.find_map(|line| extract_version_token(line.trim()))
}
fn extract_version_token(line: &str) -> Option<String> {
line.split_whitespace()
.find(|token| token.chars().next().is_some_and(|c| c.is_ascii_digit()))
.map(|value| value.trim_start_matches('v').to_string())
.or_else(|| {
line.split_whitespace()
.last()
.map(|value| value.trim_start_matches('v').to_string())
})
}
fn probe_gdtest_harness_version(
context: &ProjectContext,
_config: &GdsuiteConfig,
) -> Option<String> {
let harness_path = context.root.join(GDTEST_HARNESS_PATH);
let body = fs::read_to_string(harness_path).ok()?;
extract_quoted_assignment(&body, "HARNESS_VERSION")
}
fn probe_gdhealth_schema_version(path: &Path, context: &ProjectContext) -> Option<u32> {
for args in [["--schema-version"], ["schema-version"]] {
let args = args
.iter()
.map(|value| value.to_string())
.collect::<Vec<_>>();
let output = process::run(path, &args, &context.root).ok()?;
if !output.status.success() {
continue;
}
if let Some(value) = output
.stdout
.lines()
.find_map(|line| line.trim().parse::<u32>().ok())
{
return Some(value);
}
}
None
}
fn extract_quoted_assignment(body: &str, name: &str) -> Option<String> {
for line in body.lines() {
let line = line.trim();
if !line.contains(name) {
continue;
}
let start = line.find('"')?;
let end = line[start + 1..].find('"')?;
return Some(line[start + 1..start + 1 + end].to_string());
}
None
}
fn normalize_spec(spec: &str) -> String {
spec.trim_start_matches('=').trim().to_string()
}
fn normalize_version(version: &str) -> String {
version.trim().trim_start_matches('v').to_string()
}
pub fn write_gdtoolkit_configs_if_missing(context: &ProjectContext) -> Result<()> {
const EXCLUDES: &str = "excluded_directories: !!set\n .git: null\n .godot: null\n .gdsuite: null\n addons: null\n";
for name in ["gdformatrc", "gdlintrc"] {
let dotted = context.root.join(format!(".{name}"));
if !dotted.exists() && !context.root.join(name).exists() {
fs::write(&dotted, EXCLUDES)
.with_context(|| format!("failed to write {}", dotted.display()))?;
}
}
Ok(())
}
pub fn install_gdtest_addons(
context: &ProjectContext,
config: &GdsuiteConfig,
) -> Result<AddonInstallReport> {
let source_root = resolved_source_root(context, &config.tools.gdtest_source, "gdtest")?;
let runtime_source = source_root.join("addons").join("gdtest");
if !runtime_source.exists() {
return Err(anyhow!(
"gdtest runtime addon source missing at {}",
runtime_source.display()
));
}
let mut report = AddonInstallReport::default();
let runtime_target_dir = harness_parent_dir(GDTEST_HARNESS_PATH);
let runtime_res_dir = res_dir_from_rel_path(&runtime_target_dir);
install_addon_dir(
&runtime_source,
&context.root.join(&runtime_target_dir),
&mut report,
|relative, contents| rewrite_runtime_asset(relative, contents, &runtime_res_dir),
)?;
let editor_source = source_root.join("addons").join("gdtest_editor");
if !editor_source.exists() {
return Err(anyhow!(
"gdtest editor addon source missing at {}",
editor_source.display()
));
}
let editor_res_dir = res_dir_from_rel_path(Path::new(GDTEST_EDITOR_PLUGIN_DIR));
install_addon_dir(
&editor_source,
&context.root.join(GDTEST_EDITOR_PLUGIN_DIR),
&mut report,
|relative, contents| rewrite_editor_asset(relative, contents, &editor_res_dir),
)?;
Ok(report)
}
fn install_addon_dir(
source_dir: &Path,
target_dir: &Path,
report: &mut AddonInstallReport,
rewrite: impl Fn(&Path, &str) -> String + Copy,
) -> Result<()> {
for entry in fs::read_dir(source_dir)
.with_context(|| format!("failed to read addon source {}", source_dir.display()))?
{
let entry = entry?;
let source_path = entry.path();
let relative = source_path.strip_prefix(source_dir).unwrap();
let target_path = target_dir.join(relative);
if entry.file_type()?.is_dir() {
fs::create_dir_all(&target_path)
.with_context(|| format!("failed to create {}", target_path.display()))?;
install_addon_dir(&source_path, &target_path, report, rewrite)?;
continue;
}
let contents = fs::read_to_string(&source_path)
.with_context(|| format!("failed to read {}", source_path.display()))?;
let rewritten = rewrite(relative, &contents);
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
if target_path.exists() {
let current = fs::read_to_string(&target_path)
.with_context(|| format!("failed to read {}", target_path.display()))?;
if current == rewritten {
continue;
}
report.skipped_modified.push(target_path);
continue;
}
fs::write(&target_path, rewritten)
.with_context(|| format!("failed to write {}", target_path.display()))?;
report.installed_files += 1;
}
Ok(())
}
fn rewrite_runtime_asset(relative: &Path, contents: &str, runtime_res_dir: &str) -> String {
let runtime_prefix = join_res_path(runtime_res_dir, "gdunit4_chain.gd");
let compat_prefix = join_res_path(runtime_res_dir, "gdunit4_compat.gd");
match relative.to_string_lossy().replace('\\', "/").as_str() {
"gdtest_worker.gd" => contents
.replace("res://addons/gdtest/gdunit4_chain.gd", &runtime_prefix)
.replace("res://addons/gdtest/gdunit4_compat.gd", &compat_prefix),
"gdunit4_compat.gd" => {
contents.replace("res://addons/gdtest/gdunit4_chain.gd", &runtime_prefix)
}
_ => contents.to_string(),
}
}
fn rewrite_editor_asset(relative: &Path, contents: &str, editor_res_dir: &str) -> String {
match relative.to_string_lossy().replace('\\', "/").as_str() {
"plugin.gd" => contents.replace(
"res://addons/gdtest_editor/test_panel.gd",
&join_res_path(editor_res_dir, "test_panel.gd"),
),
_ => contents.to_string(),
}
}
fn harness_parent_dir(harness: &str) -> PathBuf {
Path::new(harness)
.parent()
.map(Path::to_path_buf)
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| PathBuf::from("."))
}
fn res_dir_from_rel_path(path: &Path) -> String {
let normalized = path.display().to_string().replace('\\', "/");
if normalized == "." || normalized.is_empty() {
"res://".to_string()
} else {
format!("res://{}", normalized.trim_end_matches('/'))
}
}
fn join_res_path(base: &str, file: &str) -> String {
if base.ends_with('/') {
format!("{base}{file}")
} else {
format!("{base}/{file}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ToolAcquireSpec;
#[test]
fn test_parse_github_release_repo_spec() {
let spec = parse_tool_source_spec("github:q-rault/gdtest@v0.1.0", "gdtest");
assert!(
matches!(spec, ToolAcquireSpec::GithubReleaseRepo { ref owner, ref repo, ref tag }
if owner == "q-rault" && repo == "gdtest" && tag == "v0.1.0")
);
}
#[test]
fn test_parse_github_release_url_still_works() {
let spec = parse_tool_source_spec("github:https://example.com/asset.tar.gz", "gdtest");
assert!(matches!(spec, ToolAcquireSpec::GithubRelease { ref url }
if url == "https://example.com/asset.tar.gz"));
}
#[test]
fn test_current_target_is_known_platform() {
let known = [
("x86_64-pc-windows-msvc", "zip"),
("x86_64-apple-darwin", "tar.gz"),
("aarch64-apple-darwin", "tar.gz"),
("x86_64-unknown-linux-musl", "tar.gz"),
];
let result = current_release_asset_target();
assert!(
result.is_ok(),
"current platform is not supported: {}-{}",
std::env::consts::ARCH,
std::env::consts::OS
);
let (target, ext) = result.unwrap();
assert!(
known.contains(&(target, ext)),
"unexpected target/ext pair: {target}/{ext}"
);
}
#[test]
fn test_github_release_asset_url_format() {
unsafe { std::env::remove_var("GDSUITE_GITHUB_RELEASE_BASE_URL") };
let url = github_release_asset_url("q-rault", "gdtest", "v0.1.0", "gdtest").unwrap();
assert!(
url.starts_with(
"https://github.com/q-rault/gdtest/releases/download/v0.1.0/gdtest-v0.1.0-"
),
"unexpected URL prefix: {url}"
);
assert!(
url.ends_with(".zip") || url.ends_with(".tar.gz"),
"unexpected URL suffix: {url}"
);
}
}