use crate::cargo::cargo_info_provider;
use crate::command_runner::{Command, CommandChain};
use crate::config::Config;
use crate::download_client::DownloadClient;
use crate::github::GitHubClient;
use crate::godot_version::GodotVersion;
use crate::installer::ensure_installed;
use crate::path_extension::PathExt;
use crate::project_specification::{
ProjectSpecError, ProjectSpecification, load_godot_project_spec,
};
use anyhow::{Context, Result, bail};
use once_cell::sync::Lazy;
use std::path::{Path, PathBuf};
use tokio::runtime::Runtime;
static RUNTIME: Lazy<Runtime> = Lazy::new(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to create Tokio runtime")
});
pub struct GodotRunner<D: DownloadClient> {
config: Option<Config>,
download_client: Option<D>,
godot_version: Option<GodotVersion>,
godot_cli_arguments: Option<Vec<String>>,
godot_project_path: Option<PathBuf>,
pre_import: bool,
}
impl<D: DownloadClient> GodotRunner<D> {
pub fn build(&self) -> Result<CommandChain> {
let working_dir = std::env::current_dir()?;
let future = self.build_at(&working_dir);
match tokio::runtime::Handle::try_current() {
Ok(handle) => {
tokio::task::block_in_place(|| handle.block_on(future))
}
Err(_) => {
RUNTIME.block_on(future)
}
}
.context("Gdenv failed to run Godot")
}
pub async fn build_at(&self, working_dir: &Path) -> Result<CommandChain> {
let Some(config) = self.config.as_ref() else {
bail!("A data directory configuration must be specified.");
};
let Some(download_client) = self.download_client.as_ref() else {
bail!("A download client must be specified.");
};
let spec_from_file = load_godot_project_spec(working_dir, cargo_info_provider()).or_else(
|error| -> Result<ProjectSpecification> {
match error {
ProjectSpecError::NotFound => Ok(ProjectSpecification {
project_root_dir: working_dir.to_path_buf(),
spec_file_path: None,
godot_version: self.godot_version.clone().context(
"No Godot version specified and no configuration file found.",
)?,
godot_project_dir: working_dir.to_path_buf(),
run_args: vec![],
editor_args: vec![],
pre_import: true,
gdextension: Default::default(),
addons: Default::default(),
}),
_ => bail!(error),
}
},
)?;
let project_working_dir = spec_from_file
.spec_file_path
.as_deref()
.and_then(|path| path.parent())
.unwrap_or(working_dir);
let project_spec = ProjectSpecification {
spec_file_path: spec_from_file.spec_file_path.clone(),
godot_version: self
.godot_version
.clone()
.unwrap_or(spec_from_file.godot_version),
run_args: self
.godot_cli_arguments
.clone()
.unwrap_or(spec_from_file.run_args),
godot_project_dir: self
.godot_project_path
.clone()
.unwrap_or(spec_from_file.godot_project_dir)
.to_absolute(project_working_dir)?,
..spec_from_file
};
for (name, generator) in project_spec.gdextension {
generator
.build(project_working_dir)
.context(format!("Failed to build GDExtension file: {}", name))?
.write()?;
}
let executable_path =
ensure_installed(config, &project_spec.godot_version, download_client, false)
.await
.context(format!(
"Failed to install Godot version {}",
project_spec.godot_version
))?;
let mut command_chain = CommandChain::new();
if self.pre_import
&& project_spec
.godot_project_dir
.join("project.godot")
.exists()
&& !project_spec.godot_project_dir.join(".godot").exists()
{
let failure_message = "Possible cause: Known bug in Godot 4.5.1: \"Headless import of project with GDExtensions crashes\"\n\
See: https://github.com/godotengine/godot/issues/111645\n\
Try re-running if `.godot` folder was generated successfully.";
command_chain.append(Command {
executable: executable_path.clone(),
working_dir: project_spec.godot_project_dir.clone(),
args: vec!["--import".to_string(), "--headless".to_string()],
failure_message: Some(failure_message.to_string()),
});
}
command_chain.append(Command {
executable: executable_path,
working_dir: project_spec.godot_project_dir.clone(),
args: ["--path".to_string(), ".".to_string()]
.into_iter()
.chain(project_spec.run_args.iter().cloned())
.collect(),
failure_message: None,
});
Ok(command_chain)
}
pub fn config(self, config: Option<Config>) -> Self {
Self { config, ..self }
}
pub fn download_client(self, download_client: Option<D>) -> Self {
Self {
download_client,
..self
}
}
pub fn godot_version(self, godot_version: Option<GodotVersion>) -> Self {
Self {
godot_version,
..self
}
}
pub fn godot_cli_arguments(self, godot_cli_arguments: Option<Vec<String>>) -> Self {
Self {
godot_cli_arguments,
..self
}
}
pub fn godot_project_path(self, godot_project_path: Option<PathBuf>) -> Self {
Self {
godot_project_path,
..self
}
}
pub fn pre_import(self, pre_import: bool) -> Self {
Self { pre_import, ..self }
}
}
impl<D: DownloadClient> Default for GodotRunner<D> {
fn default() -> Self {
Self {
config: None,
download_client: None,
godot_version: None,
godot_cli_arguments: None,
godot_project_path: None,
pre_import: true,
}
}
}
impl GodotRunner<GitHubClient> {
pub fn init() -> Result<GodotRunner<GitHubClient>> {
Self::init_with_custom_data_dir(Some(&Config::default_data_dir()))
}
pub fn init_with_custom_data_dir(data_dir: Option<&Path>) -> Result<GodotRunner<GitHubClient>> {
let config = Config::setup(data_dir)?;
let github_client = GitHubClient::new(config.clone());
Ok(GodotRunner {
config: Some(config),
download_client: Some(github_client),
godot_version: None,
godot_cli_arguments: None,
godot_project_path: None,
pre_import: true,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::installer::get_executable_path;
use crate::test_helpers::mock_download_client::MockDownloadClient;
use anyhow::Result;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
#[tokio::test]
async fn test_create_from_project_root() -> Result<()> {
let (tmp_dir, project_dir, godot_project_dir, config, runner) = initialize_project()?;
let command_chain = runner.build_at(&project_dir).await?;
verify_project(
tmp_dir,
project_dir,
godot_project_dir,
&config,
command_chain,
)?;
Ok(())
}
#[tokio::test]
async fn test_create_from_sub_directory() -> Result<()> {
let (tmp_dir, project_dir, godot_project_dir, config, runner) = initialize_project()?;
let command_chain = runner.build_at(&godot_project_dir).await?;
verify_project(
tmp_dir,
project_dir,
godot_project_dir,
&config,
command_chain,
)?;
Ok(())
}
fn initialize_project() -> Result<(
TempDir,
PathBuf,
PathBuf,
Config,
GodotRunner<MockDownloadClient>,
)> {
let tmp_dir = tempfile::Builder::new().prefix("gdenv-lib").tempdir()?;
let data_dir = tmp_dir.path().join("data");
let project_dir = tmp_dir.path().join("project");
let godot_project_dir = project_dir.join("godot");
copy_dir_all("test-data/godot_runner_mock_project", &project_dir)
.context("Failed to copy test data")?;
let config = Config::setup(Some(&data_dir))?;
let runner = GodotRunner::default()
.config(Some(config.clone()))
.download_client(Some(MockDownloadClient));
Ok((tmp_dir, project_dir, godot_project_dir, config, runner))
}
fn verify_project(
_tmp_dir: TempDir,
_project_dir: PathBuf,
godot_project_dir: PathBuf,
config: &Config,
command_chain: CommandChain,
) -> Result<()> {
assert_eq!(command_chain.commands().len(), 2);
assert_eq!(
command_chain.commands()[0].executable.canonicalize()?,
get_executable_path(&config, &GodotVersion::new("4.2.1-stable", false)?)?
.canonicalize()?
);
assert_eq!(
command_chain.commands()[0].working_dir.canonicalize()?,
godot_project_dir.clone().canonicalize()?
);
assert_eq!(
command_chain.commands()[0].args,
vec!["--import".to_string(), "--headless".to_string()]
);
assert_eq!(
command_chain.commands()[1].executable.canonicalize()?,
get_executable_path(&config, &GodotVersion::new("4.2.1-stable", false)?)?
.canonicalize()?
);
assert_eq!(
command_chain.commands()[1].working_dir.canonicalize()?,
godot_project_dir.clone().canonicalize()?
);
assert_eq!(
command_chain.commands()[1].args,
vec!["--path".to_string(), ".".to_string()]
);
Ok(())
}
fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
fs::create_dir_all(&dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
if ty.is_dir() {
copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
} else {
fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
}
}
Ok(())
}
}