use crate::cargo::CargoInfoProvider;
use crate::gdextension_config::GdExtensionConfig;
use crate::godot_version::GodotVersion;
use anyhow::{Context, Result};
use documented::{Documented, DocumentedFieldsOpt};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ProjectSpecError {
#[error(
"No gdenv.toml or .godot-version file found in current directory or in parent directories."
)]
NotFound,
#[error("Failed to parse Godot project configuration file {0}: {1}")]
ParseError(PathBuf, String),
#[error(transparent)]
IoError(#[from] std::io::Error),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, Eq, PartialEq, Clone)]
pub struct ProjectSpecification {
pub project_root_dir: PathBuf,
pub spec_file_path: Option<PathBuf>,
pub godot_version: GodotVersion,
pub godot_project_dir: PathBuf,
pub run_args: Vec<String>,
pub editor_args: Vec<String>,
pub pre_import: bool,
pub gdextension: HashMap<String, GdExtensionConfig>,
pub addons: HashMap<String, AddonSpec>,
}
#[derive(Serialize, Deserialize, Documented, Debug, Default)]
#[serde(deny_unknown_fields)]
pub struct ProjectSpecificationToml {
pub godot: SpecGodot,
pub gdextension: Option<HashMap<String, SpecGdExtensionGenerator>>,
pub addon: Option<HashMap<String, AddonSpec>>,
}
#[derive(Serialize, Deserialize, Documented, DocumentedFieldsOpt, Debug, Default)]
#[serde(deny_unknown_fields)]
pub struct SpecGodot {
pub version: String,
pub dotnet: Option<bool>,
pub project_dir: Option<PathBuf>,
pub run_args: Option<Vec<String>>,
pub editor_args: Option<Vec<String>>,
pub pre_import: Option<bool>,
}
#[derive(Serialize, Deserialize, Documented, Debug, Eq, PartialEq, Clone)]
#[serde(deny_unknown_fields)]
pub enum SpecGdExtensionGenerator {
Rust(SpecRustGdExtension),
}
#[derive(
Serialize, Deserialize, Documented, DocumentedFieldsOpt, Debug, Eq, PartialEq, Clone, Default,
)]
#[serde(deny_unknown_fields)]
pub struct SpecRustGdExtension {
pub cargo_crate_path: PathBuf,
pub config_name: Option<String>,
pub compatability_version: Option<String>,
pub entry_symbol: Option<String>,
pub reloadable: Option<bool>,
}
#[derive(Serialize, Deserialize, Documented, DocumentedFieldsOpt, Debug, Eq, PartialEq, Clone)]
pub struct AddonSpec {
pub include: Option<Vec<PathBuf>>,
pub exclude: Option<Vec<PathBuf>>,
pub destination: Option<PathBuf>,
#[serde(flatten)]
pub source: AddonSource,
}
#[derive(Serialize, Deserialize, Documented, Debug, Eq, PartialEq, Clone)]
#[serde(untagged, rename = "addon source type")]
pub enum AddonSource {
Git(GitAddonSource),
Local(LocalAddonSource),
}
#[derive(Serialize, Deserialize, Documented, DocumentedFieldsOpt, Debug, Eq, PartialEq, Clone)]
#[serde(deny_unknown_fields)]
pub struct GitAddonSource {
pub git: String,
pub rev: Option<String>,
pub subdir: Option<PathBuf>,
}
#[derive(Serialize, Deserialize, Documented, DocumentedFieldsOpt, Debug, Eq, PartialEq, Clone)]
#[serde(deny_unknown_fields)]
pub struct LocalAddonSource {
pub path: PathBuf,
}
pub fn spec_documentation() -> Result<String> {
let out = [
struct_doc::<ProjectSpecificationToml>(),
struct_doc_f::<SpecGodot>(),
struct_doc::<SpecGdExtensionGenerator>(),
struct_doc_f::<SpecRustGdExtension>(),
struct_doc_f::<AddonSpec>(),
struct_doc_f::<GitAddonSource>(),
struct_doc_f::<LocalAddonSource>(),
];
Ok(out.join("\n"))
}
fn struct_doc_f<T: Documented + DocumentedFieldsOpt>() -> String {
let fields = T::FIELD_DOCS
.iter()
.filter_map(|x| *x)
.map(|doc| format!("{}\n", doc));
[
T::DOCS.to_string(),
fields.collect::<Vec<String>>().join("\n"),
]
.join("\n\n")
}
fn struct_doc<T: Documented>() -> String {
[T::DOCS, ""].join("\n")
}
pub fn load_godot_project_spec<P: CargoInfoProvider>(
start_path: &Path,
cargo_target_path_provider: P,
) -> Result<ProjectSpecification, ProjectSpecError> {
let spec_file = find_godot_project_spec(start_path)?;
match spec_file {
SpecFileType::Toml {
dir_path,
file_path,
} => {
let str_spec = fs::read_to_string(&file_path)?;
let spec = toml::from_str::<ProjectSpecificationToml>(&str_spec).context(format!(
"Failed to parse Godot project configuration file gdenv.toml: {}",
file_path.display()
))?;
let project_dir = spec.godot.project_dir.unwrap_or(PathBuf::from("."));
Ok(ProjectSpecification {
project_root_dir: dir_path,
spec_file_path: Some(file_path.clone()),
godot_version: GodotVersion::new(
&spec.godot.version,
spec.godot.dotnet.unwrap_or(false),
)?,
godot_project_dir: project_dir.clone(),
run_args: spec.godot.run_args.unwrap_or_default(),
editor_args: spec.godot.editor_args.unwrap_or_default(),
pre_import: spec.godot.pre_import.unwrap_or(true),
gdextension: gdextension_generator_to_config(
file_path.parent().unwrap_or(start_path),
spec.gdextension.unwrap_or_default(),
&project_dir,
cargo_target_path_provider,
)?,
addons: spec.addon.unwrap_or_default(),
})
}
SpecFileType::Version {
dir_path,
file_path,
} => {
let file_content = fs::read_to_string(&file_path)?;
let mut version_str = file_content.trim().split(' ');
let version = version_str
.next()
.context("No version specified in .godot-version file.")?;
let dotnet = version_str.next().unwrap_or("");
Ok(ProjectSpecification {
project_root_dir: dir_path,
spec_file_path: Some(file_path),
godot_version: GodotVersion::new(version, dotnet == "dotnet" || dotnet == "mono")?,
godot_project_dir: PathBuf::from("."),
run_args: vec![],
editor_args: vec![],
pre_import: true,
gdextension: HashMap::default(),
addons: HashMap::default(),
})
}
}
}
fn gdextension_generator_to_config<P: CargoInfoProvider>(
working_dir: &Path,
generators: HashMap<String, SpecGdExtensionGenerator>,
godot_project_path: &Path,
cargo_info_provider: P,
) -> Result<HashMap<String, GdExtensionConfig>> {
generators
.into_iter()
.map(|(name, generator)| -> Result<(String, GdExtensionConfig)> {
match generator {
SpecGdExtensionGenerator::Rust(generator) => {
let cargo_info = &cargo_info_provider(
&working_dir
.join(&generator.cargo_crate_path)
.join("Cargo.toml"),
)?;
let mut config = GdExtensionConfig::start(
&cargo_info.crate_name,
&working_dir.join(godot_project_path),
&cargo_info.target_dir,
);
if let Some(config_name) = &generator.config_name {
config = config.config_file_name(&format!("{}.gdextension", config_name));
}
if let Some(compatability_version) = &generator.compatability_version {
config = config.compatability_version(compatability_version);
}
if let Some(entry_symbol) = &generator.entry_symbol {
config = config.entry_symbol(entry_symbol);
}
if let Some(reloadable) = generator.reloadable {
config = config.reloadable(reloadable);
}
Ok((name, config))
}
}
})
.collect()
}
enum SpecFileType {
Toml {
dir_path: PathBuf,
file_path: PathBuf,
},
Version {
dir_path: PathBuf,
file_path: PathBuf,
},
}
fn find_godot_project_spec(start_path: &Path) -> Result<SpecFileType, ProjectSpecError> {
let mut current_dir = start_path.to_path_buf();
loop {
let toml_path = current_dir.join("gdenv.toml");
if toml_path.exists() {
return Ok(SpecFileType::Toml {
dir_path: current_dir,
file_path: toml_path,
});
}
let version_path = current_dir.join(".godot-version");
if version_path.exists() {
return Ok(SpecFileType::Version {
dir_path: current_dir,
file_path: version_path,
});
}
if !current_dir.pop() {
break;
}
}
Err(ProjectSpecError::NotFound)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cargo::CargoInfo;
use crate::godot_version::GodotVersion;
use anyhow::bail;
#[test]
fn test_gdenv_toml_project_spec_full() -> Result<()> {
let tmp_dir = tempfile::Builder::new().prefix("gdenv-test").tempdir()?;
let version_file = tmp_dir.path().join("gdenv.toml");
let str_spec = r#"
[godot]
version = "4.6.0-stable"
dotnet = true
project_dir = "./godot"
run_args = ["arg1", "arg2"]
editor_args = ["arg3", "arg4"]
pre_import = false
[gdextension.config_a.Rust]
cargo_crate_path = "rust"
config_name = "not_rust"
compatability_version = "4.2"
entry_symbol = "my_entry_symbol"
reloadable = true
[gdextension.config_b.Rust]
cargo_crate_path = "rust"
[addon.dialogic]
git = "https://github.com/dialogic-godot/dialogic"
rev = "main"
[addon.curtains]
git = "https://github.com/DragonAxe/gd-bvy-curtains"
rev = "other_ref"
[addon.gdunit4]
git = "https://github.com/godot-gdunit-labs/gdUnit4"
include = ["addons/gdUnit4"]
[addon.local-project]
path = "../local-project"
"#;
fs::write(&version_file, str_spec)?;
let cargo_info = CargoInfo {
crate_name: "my_gdextension".to_string(),
target_dir: PathBuf::from("/home/user/.cache/cargo/target"),
};
let spec = load_godot_project_spec(tmp_dir.path(), |_| Ok(cargo_info.clone()))?;
let expected_spec = ProjectSpecification {
project_root_dir: tmp_dir.path().to_path_buf(),
spec_file_path: Some(version_file),
godot_version: GodotVersion::new("4.6.0", true)?,
godot_project_dir: PathBuf::from("./godot"),
run_args: vec!["arg1".to_string(), "arg2".to_string()],
editor_args: vec!["arg3".to_string(), "arg4".to_string()],
pre_import: false,
gdextension: HashMap::from([
(
"config_a".to_string(),
GdExtensionConfig::start(
&cargo_info.crate_name,
&tmp_dir.path().join("./godot"),
&cargo_info.target_dir,
)
.config_file_name("not_rust.gdextension")
.compatability_version("4.2")
.entry_symbol("my_entry_symbol")
.reloadable(true),
),
(
"config_b".to_string(),
GdExtensionConfig::start(
&cargo_info.crate_name,
&tmp_dir.path().join("./godot"),
&cargo_info.target_dir,
),
),
]),
addons: HashMap::from([
(
"dialogic".to_string(),
AddonSpec {
include: None,
exclude: None,
destination: None,
source: AddonSource::Git(GitAddonSource {
git: "https://github.com/dialogic-godot/dialogic".to_string(),
rev: Some("main".to_string()),
subdir: None,
}),
},
),
(
"curtains".to_string(),
AddonSpec {
include: None,
exclude: None,
destination: None,
source: AddonSource::Git(GitAddonSource {
git: "https://github.com/DragonAxe/gd-bvy-curtains".to_string(),
rev: Some("other_ref".to_string()),
subdir: None,
}),
},
),
(
"gdunit4".to_string(),
AddonSpec {
include: Some(vec![PathBuf::from("addons/gdUnit4")]),
exclude: None,
destination: None,
source: AddonSource::Git(GitAddonSource {
git: "https://github.com/godot-gdunit-labs/gdUnit4".to_string(),
rev: None,
subdir: None,
}),
},
),
(
"local-project".to_string(),
AddonSpec {
include: None,
exclude: None,
destination: None,
source: AddonSource::Local(LocalAddonSource {
path: PathBuf::from("../local-project"),
}),
},
),
]),
};
assert_eq!(spec, expected_spec);
Ok(())
}
#[test]
fn test_gdenv_toml_project_spec_minimal() -> Result<()> {
let tmp_dir = tempfile::Builder::new().prefix("gdenv-test").tempdir()?;
let version_file = tmp_dir.path().join("gdenv.toml");
let str_spec = r#"
[godot]
version = "4.6.0"
"#;
fs::write(version_file, str_spec)?;
let spec =
load_godot_project_spec(tmp_dir.path(), |_| bail!("Test lambda not implemented."))?;
assert_eq!(spec.godot_version, GodotVersion::new("4.6.0", false)?);
Ok(())
}
#[test]
fn test_gdenv_toml_project_spec_empty() -> Result<()> {
let tmp_dir = tempfile::Builder::new().prefix("gdenv-test").tempdir()?;
let version_file = tmp_dir.path().join("gdenv.toml");
let str_spec = r#""#;
fs::write(version_file, str_spec)?;
let spec =
load_godot_project_spec(tmp_dir.path(), |_| bail!("Test lambda not implemented."));
assert!(spec.is_err());
Ok(())
}
#[test]
fn test_godot_version_file_full() -> Result<()> {
let tmp_dir = tempfile::Builder::new().prefix("gdenv-test").tempdir()?;
let version_file = tmp_dir.path().join(".godot-version");
let str_spec = "4.6 dotnet";
fs::write(version_file, str_spec)?;
let spec =
load_godot_project_spec(tmp_dir.path(), |_| bail!("Test lambda not implemented."))?;
assert_eq!(spec.godot_version, GodotVersion::new("4.6.0-stable", true)?);
Ok(())
}
#[test]
fn test_godot_version_file_version_only() -> Result<()> {
let tmp_dir = tempfile::Builder::new().prefix("gdenv-test").tempdir()?;
let version_file = tmp_dir.path().join(".godot-version");
let str_spec = "4.6";
fs::write(version_file, str_spec)?;
let spec =
load_godot_project_spec(tmp_dir.path(), |_| bail!("Test lambda not implemented."))?;
assert_eq!(
spec.godot_version,
GodotVersion::new("4.6.0-stable", false)?
);
Ok(())
}
#[test]
fn test_godot_version_file_empty() -> Result<()> {
let tmp_dir = tempfile::Builder::new().prefix("gdenv-test").tempdir()?;
let version_file = tmp_dir.path().join(".godot-version");
let str_spec = "";
fs::write(version_file, str_spec)?;
let spec =
load_godot_project_spec(tmp_dir.path(), |_| bail!("Test lambda not implemented."));
assert!(spec.is_err());
Ok(())
}
}