use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::process::Command;
use bevy::log::{info, warn};
use crate::sdk_paths::SdkPaths;
pub const TEMPLATE_REPO_URL: &str = "https://github.com/jbuehler23/jackdaw";
pub const TEMPLATE_EXTENSION_STATIC_SUBDIR: &str = "templates/extension-static";
pub const TEMPLATE_EXTENSION_DYLIB_SUBDIR: &str = "templates/extension";
pub const TEMPLATE_GAME_STATIC_SUBDIR: &str = "templates/game-static";
pub const TEMPLATE_GAME_DYLIB_SUBDIR: &str = "templates/game";
pub const TEMPLATE_DEFAULT_BRANCH: &str = "main";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TemplateLinkage {
#[default]
Static,
Dylib,
}
#[derive(Clone, Debug)]
pub enum TemplatePreset {
Extension,
Game,
Custom(String),
}
impl TemplatePreset {
pub fn url(&self, linkage: TemplateLinkage) -> String {
match (self.git_url(linkage), self.subdir(linkage)) {
(url, Some(subdir)) => format!("{url} {subdir}"),
(url, None) => url.into_owned(),
}
}
pub fn git_url_with_subdir(&self, linkage: TemplateLinkage) -> String {
match (self.git_url_only(linkage), self.subdir(linkage)) {
(url, Some(subdir)) => format!("{url} {subdir}"),
(url, None) => url.into_owned(),
}
}
pub fn git_url_only(&self, linkage: TemplateLinkage) -> Cow<'static, str> {
let preset_override = match self {
Self::Extension => match linkage {
TemplateLinkage::Static => {
std::env::var("JACKDAW_TEMPLATE_EXTENSION_STATIC_URL").ok()
}
TemplateLinkage::Dylib => std::env::var("JACKDAW_TEMPLATE_EXTENSION_DYLIB_URL")
.or_else(|_| std::env::var("JACKDAW_TEMPLATE_EXTENSION_URL"))
.ok(),
},
Self::Game => match linkage {
TemplateLinkage::Static => std::env::var("JACKDAW_TEMPLATE_GAME_STATIC_URL").ok(),
TemplateLinkage::Dylib => std::env::var("JACKDAW_TEMPLATE_GAME_DYLIB_URL")
.or_else(|_| std::env::var("JACKDAW_TEMPLATE_GAME_URL"))
.ok(),
},
Self::Custom(url) => Some(url.split_whitespace().next().unwrap_or(url).to_string()),
};
if let Some(url) = preset_override {
return Cow::Owned(url);
}
if let Ok(repo) = std::env::var("JACKDAW_TEMPLATE_REPO_URL") {
return Cow::Owned(repo);
}
Cow::Borrowed(TEMPLATE_REPO_URL)
}
pub fn local_template_path(&self, linkage: TemplateLinkage) -> Option<PathBuf> {
if !matches!(self, Self::Extension | Self::Game) {
return None;
}
let checkout = jackdaw_dev_checkout()?;
let subdir = self.subdir(linkage)?;
Some(checkout.join(subdir))
}
pub fn git_url(&self, linkage: TemplateLinkage) -> Cow<'static, str> {
if matches!(self, Self::Extension | Self::Game)
&& let Some(checkout) = jackdaw_dev_checkout()
{
return Cow::Owned(checkout.display().to_string());
}
let preset_override = match self {
Self::Extension => match linkage {
TemplateLinkage::Static => {
std::env::var("JACKDAW_TEMPLATE_EXTENSION_STATIC_URL").ok()
}
TemplateLinkage::Dylib => std::env::var("JACKDAW_TEMPLATE_EXTENSION_DYLIB_URL")
.or_else(|_| std::env::var("JACKDAW_TEMPLATE_EXTENSION_URL"))
.ok(),
},
Self::Game => match linkage {
TemplateLinkage::Static => std::env::var("JACKDAW_TEMPLATE_GAME_STATIC_URL").ok(),
TemplateLinkage::Dylib => std::env::var("JACKDAW_TEMPLATE_GAME_DYLIB_URL")
.or_else(|_| std::env::var("JACKDAW_TEMPLATE_GAME_URL"))
.ok(),
},
Self::Custom(url) => Some(url.split_whitespace().next().unwrap_or(url).to_string()),
};
if let Some(url) = preset_override {
return Cow::Owned(url);
}
if let Ok(repo) = std::env::var("JACKDAW_TEMPLATE_REPO_URL") {
return Cow::Owned(repo);
}
Cow::Borrowed(TEMPLATE_REPO_URL)
}
pub fn subdir(&self, linkage: TemplateLinkage) -> Option<&str> {
match self {
Self::Extension => Some(match linkage {
TemplateLinkage::Static => TEMPLATE_EXTENSION_STATIC_SUBDIR,
TemplateLinkage::Dylib => TEMPLATE_EXTENSION_DYLIB_SUBDIR,
}),
Self::Game => Some(match linkage {
TemplateLinkage::Static => TEMPLATE_GAME_STATIC_SUBDIR,
TemplateLinkage::Dylib => TEMPLATE_GAME_DYLIB_SUBDIR,
}),
Self::Custom(url) => {
let mut parts = url.split_whitespace();
let _ = parts.next();
parts.next().map(|s| {
Box::leak(s.to_string().into_boxed_str()) as &'static str
})
}
}
}
pub fn supports_linkage_selector(&self) -> bool {
matches!(self, Self::Extension | Self::Game)
}
}
pub enum TemplateKind {
StaticGameWithEditorFeature,
DylibGame,
Other,
}
pub fn detect_template_kind(project_root: &Path) -> TemplateKind {
let manifest = project_root.join("Cargo.toml");
let Ok(text) = std::fs::read_to_string(&manifest) else {
return TemplateKind::Other;
};
let normalized: String = text.chars().filter(|c| !c.is_whitespace()).collect();
let has_editor_feature = normalized.contains("editor=[\"dep:jackdaw\"]")
|| normalized.contains("editor=[\"dep:jackdaw\",");
let has_editor_bin = normalized.contains("name=\"editor\"")
&& normalized.contains("required-features=[\"editor\"]");
if has_editor_feature && has_editor_bin {
return TemplateKind::StaticGameWithEditorFeature;
}
if normalized.contains("crate-type=[\"cdylib\"]") {
return TemplateKind::DylibGame;
}
TemplateKind::Other
}
pub fn jackdaw_dev_checkout() -> Option<PathBuf> {
if let Ok(p) = std::env::var("JACKDAW_DEV_CHECKOUT") {
let path = PathBuf::from(p);
if path.is_dir() {
return Some(path);
}
}
let compile_time = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut candidate = compile_time.as_path();
loop {
if candidate.join("templates").is_dir() && candidate.join("Cargo.toml").is_file() {
return Some(candidate.to_path_buf());
}
candidate = candidate.parent()?;
}
}
pub fn template_branch() -> String {
std::env::var("JACKDAW_TEMPLATE_BRANCH").unwrap_or_else(|_| TEMPLATE_DEFAULT_BRANCH.to_string())
}
#[derive(Debug)]
pub enum ScaffoldError {
BevyCliNotFound,
CargoGenerateNotFound,
InvalidName(String),
LocationNotFound(PathBuf),
ProjectAlreadyExists(PathBuf),
BevyCliFailed {
status: std::process::ExitStatus,
stdout: String,
stderr: String,
},
Spawn(std::io::Error),
}
impl std::fmt::Display for ScaffoldError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BevyCliNotFound => write!(
f,
"`bevy` CLI not found on PATH. Install with \
`cargo install --locked --git https://github.com/TheBevyFlock/bevy_cli bevy_cli`."
),
Self::CargoGenerateNotFound => write!(
f,
"`cargo-generate` not found on PATH (needed for local-path \
templates). Install with `cargo install cargo-generate`."
),
Self::InvalidName(name) => write!(
f,
"`{name}` is not a valid project name. Use lowercase letters, \
digits, hyphens, and underscores only."
),
Self::LocationNotFound(p) => write!(f, "location does not exist: {}", p.display()),
Self::ProjectAlreadyExists(p) => write!(
f,
"a project already exists at {}; pick a different name or location.",
p.display()
),
Self::BevyCliFailed { status, stderr, .. } => {
write!(f, "bevy CLI exited with {status}\n{stderr}")
}
Self::Spawn(e) => write!(f, "failed to spawn `bevy`: {e}"),
}
}
}
impl std::error::Error for ScaffoldError {}
pub fn scaffold_project(
name: &str,
location: &Path,
template_url: &str,
branch: Option<&str>,
linkage: TemplateLinkage,
) -> Result<PathBuf, ScaffoldError> {
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(ScaffoldError::InvalidName(name.to_string()));
}
if !location.is_dir() {
return Err(ScaffoldError::LocationNotFound(location.to_path_buf()));
}
let project_path = location.join(name);
if project_path.exists() {
return Err(ScaffoldError::ProjectAlreadyExists(project_path));
}
let mut parts = template_url.split_whitespace();
let template_arg = parts.next().unwrap_or("").to_string();
let subdir = parts.next();
if Path::new(&template_arg).is_dir() {
return scaffold_from_local_path(
name,
location,
&template_arg,
subdir,
linkage,
&project_path,
);
}
let bevy = which_bevy().ok_or(ScaffoldError::BevyCliNotFound)?;
let mut cmd = Command::new(&bevy);
cmd.current_dir(location)
.args(["new", "-t", &template_arg, "--yes", name]);
let resolved_branch = branch.map(str::to_owned).unwrap_or_else(template_branch);
let needs_passthrough = !resolved_branch.is_empty() || subdir.is_some();
if needs_passthrough {
cmd.arg("--");
if !resolved_branch.is_empty() {
cmd.args(["--branch", resolved_branch.as_str()]);
}
if let Some(subdir) = subdir {
cmd.arg(subdir);
}
}
let output = cmd.output().map_err(ScaffoldError::Spawn)?;
if !output.status.success() {
return Err(ScaffoldError::BevyCliFailed {
status: output.status,
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
if matches!(linkage, TemplateLinkage::Dylib) {
write_cargo_config(&project_path);
}
rewrite_jackdaw_dep_for_dev_checkout(&project_path, linkage);
Ok(project_path)
}
fn scaffold_from_local_path(
name: &str,
location: &Path,
local_root: &str,
subdir: Option<&str>,
linkage: TemplateLinkage,
project_path: &Path,
) -> Result<PathBuf, ScaffoldError> {
let cargo_generate = which_cargo_generate().ok_or(ScaffoldError::CargoGenerateNotFound)?;
let template_path = match subdir {
Some(s) => Path::new(local_root).join(s),
None => Path::new(local_root).to_path_buf(),
};
if !template_path.is_dir() {
return Err(ScaffoldError::LocationNotFound(template_path));
}
let mut cmd = Command::new(&cargo_generate);
cmd.current_dir(location)
.arg("generate")
.arg("--path")
.arg(&template_path)
.args(["--name", name])
.arg("--destination")
.arg(location)
.arg("--silent");
let output = cmd.output().map_err(ScaffoldError::Spawn)?;
if !output.status.success() {
return Err(ScaffoldError::BevyCliFailed {
status: output.status,
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
if matches!(linkage, TemplateLinkage::Dylib) {
write_cargo_config(project_path);
}
rewrite_jackdaw_dep_for_dev_checkout(project_path, linkage);
Ok(project_path.to_path_buf())
}
fn rewrite_jackdaw_dep_for_dev_checkout(project_path: &Path, linkage: TemplateLinkage) {
let _ = linkage; let Some(checkout) = jackdaw_dev_checkout() else {
return;
};
let manifest_path = project_path.join("Cargo.toml");
let Ok(contents) = std::fs::read_to_string(&manifest_path) else {
return;
};
if !contents.contains("jackdaw = {") && !contents.contains("jackdaw=") {
return; }
let mut new_contents = String::with_capacity(contents.len());
let mut rewritten = false;
for line in contents.lines() {
let trimmed = line.trim_start();
if !rewritten && trimmed.starts_with("jackdaw = {") {
let optional = trimmed.contains("optional = true");
let mut replacement = format!(
"jackdaw = {{ path = \"{}\", default-features = false",
checkout.display()
);
if optional {
replacement.push_str(", optional = true");
}
replacement.push_str(" }");
new_contents.push_str(&replacement);
new_contents.push('\n');
rewritten = true;
continue;
}
new_contents.push_str(line);
new_contents.push('\n');
}
if rewritten && let Err(e) = std::fs::write(&manifest_path, new_contents) {
warn!(
"Failed to rewrite jackdaw dep in {} for dev checkout: {e}",
manifest_path.display()
);
} else if rewritten {
info!(
"Rewrote jackdaw dep in {} to path = \"{}\" (dev checkout detected)",
manifest_path.display(),
checkout.display()
);
}
}
fn write_cargo_config(project_path: &Path) {
let paths = SdkPaths::compute();
if !paths.dylib_exists() || !paths.wrapper_exists() {
warn!(
"Skipping .cargo/config.toml write: SDK dylib or wrapper \
not found at {}. Scaffolded project will only build through \
jackdaw's Build button until you install jackdaw or set \
JACKDAW_SDK_DIR.",
paths
.dylib
.parent()
.map(|p| p.display().to_string())
.unwrap_or_default()
);
return;
}
let cargo_dir = project_path.join(".cargo");
let config_path = cargo_dir.join("config.toml");
if config_path.exists() {
warn!(
"{} already exists; leaving it alone. Merge the following keys \
manually if you want external-IDE builds to use jackdaw's SDK: \
build.rustc-wrapper, env.JACKDAW_SDK_DYLIB, env.JACKDAW_SDK_DEPS.",
config_path.display()
);
return;
}
if let Err(e) = std::fs::create_dir_all(&cargo_dir) {
warn!("Failed to create {}: {e}", cargo_dir.display());
return;
}
let body = render_cargo_config(&paths);
if let Err(e) = std::fs::write(&config_path, body) {
warn!("Failed to write {}: {e}", config_path.display());
return;
}
info!("Wrote {}", config_path.display());
}
fn render_cargo_config(paths: &SdkPaths) -> String {
format!(
"# Activates jackdaw-rustc-wrapper so that any cargo\n\
# invocation in this project directory (terminal builds,\n\
# rust-analyzer, VSCode tasks) links the resulting cdylib\n\
# against the same bevy compilation the jackdaw editor\n\
# ships with, keeping TypeIds in sync.\n\
#\n\
# Regenerate via jackdaw's scaffolder if the SDK moves.\n\
\n\
[build]\n\
rustc-wrapper = '{wrapper}'\n\
\n\
# Windows: `rust-lld` clears MSVC's 65,535 PE export cap.\n\
# `jackdaw_sdk` re-exports the bevy + jackdaw_api surface,\n\
# which is over the cap on the default MSVC linker.\n\
[target.x86_64-pc-windows-msvc]\n\
linker = 'rust-lld'\n\
rustflags = ['-C', 'link-arg=-fuse-ld=lld']\n\
\n\
[env]\n\
JACKDAW_SDK_DYLIB = '{dylib}'\n\
JACKDAW_SDK_DEPS = '{deps}'\n",
wrapper = paths.wrapper.display(),
dylib = paths.dylib.display(),
deps = paths.deps.display(),
)
}
pub fn which_bevy() -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(if cfg!(target_os = "windows") {
"bevy.exe"
} else {
"bevy"
});
if candidate.is_file() {
return Some(candidate);
}
}
None
}
pub fn which_cargo_generate() -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(if cfg!(target_os = "windows") {
"cargo-generate.exe"
} else {
"cargo-generate"
});
if candidate.is_file() {
return Some(candidate);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn render_cargo_config_includes_windows_linker_block() {
let paths = SdkPaths {
wrapper: PathBuf::from("/abs/path/jackdaw-rustc-wrapper"),
dylib: PathBuf::from("/abs/path/libjackdaw_sdk.so"),
deps: PathBuf::from("/abs/path/deps"),
};
let body = render_cargo_config(&paths);
assert!(body.contains("[target.x86_64-pc-windows-msvc]"));
assert!(body.contains("linker = 'rust-lld'"));
assert!(body.contains("link-arg=-fuse-ld=lld"));
}
#[test]
fn render_cargo_config_preserves_wrapper_and_env_blocks() {
let paths = SdkPaths {
wrapper: PathBuf::from("/w"),
dylib: PathBuf::from("/d"),
deps: PathBuf::from("/p"),
};
let body = render_cargo_config(&paths);
assert!(body.contains("[build]"));
assert!(body.contains("rustc-wrapper = '/w'"));
assert!(body.contains("[env]"));
assert!(body.contains("JACKDAW_SDK_DYLIB = '/d'"));
assert!(body.contains("JACKDAW_SDK_DEPS = '/p'"));
}
}