use std::collections::VecDeque;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use crate::new_project::TemplateLinkage;
use crate::sdk_paths::SdkPaths;
#[derive(Debug)]
pub enum BuildError {
NotADirectory(PathBuf),
MissingCargoToml(PathBuf),
SdkNotFound {
expected_path: PathBuf,
hint: &'static str,
},
WrapperNotFound {
expected_path: PathBuf,
hint: &'static str,
},
BuildSpawn(std::io::Error),
BuildFailed {
status: std::process::ExitStatus,
stderr_tail: String,
},
OutputNotProduced {
expected: PathBuf,
},
}
impl std::fmt::Display for BuildError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotADirectory(p) => write!(f, "{} is not a directory", p.display()),
Self::MissingCargoToml(p) => {
write!(f, "{} has no Cargo.toml", p.display())
}
Self::SdkNotFound {
expected_path,
hint,
} => write!(
f,
"SDK dylib not found at {}. {}",
expected_path.display(),
hint
),
Self::WrapperNotFound {
expected_path,
hint,
} => write!(
f,
"rustc wrapper not found at {}. {}",
expected_path.display(),
hint
),
Self::BuildSpawn(e) => write!(f, "failed to spawn cargo: {e}"),
Self::BuildFailed {
status,
stderr_tail,
} => {
write!(f, "cargo exited with {status}\n{stderr_tail}")
}
Self::OutputNotProduced { expected } => write!(
f,
"cargo succeeded but no .so was produced at {}",
expected.display()
),
}
}
}
impl std::error::Error for BuildError {}
const LOG_TAIL_CAPACITY: usize = 20;
#[derive(Debug, Default, Clone)]
pub struct BuildProgress {
pub current_crate: Option<String>,
pub artifacts_done: u32,
pub artifacts_total: Option<u32>,
pub recent_log_lines: VecDeque<String>,
pub finished: bool,
}
impl BuildProgress {
pub fn push_log(&mut self, line: String) {
if self.recent_log_lines.len() >= LOG_TAIL_CAPACITY {
self.recent_log_lines.pop_front();
}
self.recent_log_lines.push_back(line);
}
pub fn fraction(&self) -> Option<f32> {
if self.finished {
return Some(1.0);
}
let total = self.artifacts_total? as f32;
if total <= 0.0 {
return None;
}
Some((self.artifacts_done as f32 / total).clamp(0.0, 1.0))
}
}
fn discover_sdk() -> Result<SdkPaths, BuildError> {
let mut paths = SdkPaths::compute();
if !paths.dylib_exists() {
return Err(BuildError::SdkNotFound {
expected_path: paths.dylib,
hint: "libjackdaw_sdk was not found. Reinstall jackdaw, \
or set JACKDAW_SDK_DIR to the directory that \
contains it.",
});
}
if !paths.wrapper_exists() {
if let Some(checkout) = crate::new_project::jackdaw_dev_checkout() {
bevy::log::info!(
"Auto-building jackdaw_rustc_wrapper from dev checkout at {}",
checkout.display()
);
let status = std::process::Command::new("cargo")
.args(["build", "-p", "jackdaw_rustc_wrapper"])
.current_dir(&checkout)
.status();
match status {
Ok(s) if s.success() => {
paths = SdkPaths::compute();
if !paths.wrapper_exists() {
return Err(BuildError::WrapperNotFound {
expected_path: paths.wrapper,
hint: "Auto-build of jackdaw_rustc_wrapper \
completed but the binary is still \
missing. Try `cargo build -p \
jackdaw_rustc_wrapper` manually.",
});
}
}
_ => {
return Err(BuildError::WrapperNotFound {
expected_path: paths.wrapper,
hint: "Auto-build of jackdaw_rustc_wrapper \
failed. Run `cargo build -p \
jackdaw_rustc_wrapper` from the jackdaw \
source checkout manually.",
});
}
}
} else {
return Err(BuildError::WrapperNotFound {
expected_path: paths.wrapper,
hint: "The jackdaw_rustc_wrapper binary was not found. \
Reinstall jackdaw to repair this.",
});
}
}
Ok(paths)
}
pub fn build_extension_project(
project_dir: &Path,
linkage: TemplateLinkage,
) -> Result<PathBuf, BuildError> {
build_extension_project_with_progress(project_dir, None, linkage)
}
pub fn build_extension_project_with_progress(
project_dir: &Path,
sink: Option<Arc<Mutex<BuildProgress>>>,
linkage: TemplateLinkage,
) -> Result<PathBuf, BuildError> {
let project_dir = project_dir
.canonicalize()
.map_err(|_| BuildError::NotADirectory(project_dir.to_path_buf()))?;
if !project_dir.is_dir() {
return Err(BuildError::NotADirectory(project_dir));
}
let manifest = project_dir.join("Cargo.toml");
if !manifest.is_file() {
return Err(BuildError::MissingCargoToml(project_dir));
}
let sdk = match linkage {
TemplateLinkage::Dylib => Some(discover_sdk()?),
TemplateLinkage::Static => None,
};
if let Some(ref s) = sink
&& let Some(total) = estimate_total_artifacts(&project_dir)
&& let Ok(mut g) = s.lock()
{
g.artifacts_total = Some(total);
}
let mut cmd = Command::new("cargo");
cmd.current_dir(&project_dir);
cmd.args([
"build",
"--manifest-path",
manifest
.to_str()
.expect("Cargo.toml path must be valid UTF-8"),
"--message-format=json-render-diagnostics",
]);
if let Some(sdk) = sdk.as_ref() {
cmd.env("RUSTC_WRAPPER", &sdk.wrapper);
cmd.env("JACKDAW_SDK_DYLIB", &sdk.dylib);
cmd.env("JACKDAW_SDK_DEPS", &sdk.deps);
}
run_cargo_with_progress(cmd, sink.as_ref())?;
match linkage {
TemplateLinkage::Dylib => {
let artifact_name = artifact_file_name(&project_dir);
let artifact = project_dir.join("target/debug").join(&artifact_name);
if !artifact.is_file() {
return Err(BuildError::OutputNotProduced { expected: artifact });
}
Ok(artifact)
}
TemplateLinkage::Static => Ok(project_dir),
}
}
pub fn build_static_editor_with_progress(
project_dir: &Path,
sink: Option<Arc<Mutex<BuildProgress>>>,
) -> Result<PathBuf, BuildError> {
let project_dir = project_dir
.canonicalize()
.map_err(|_| BuildError::NotADirectory(project_dir.to_path_buf()))?;
if !project_dir.is_dir() {
return Err(BuildError::NotADirectory(project_dir));
}
let manifest = project_dir.join("Cargo.toml");
if !manifest.is_file() {
return Err(BuildError::MissingCargoToml(project_dir));
}
if let Some(ref s) = sink
&& let Some(total) = estimate_total_artifacts(&project_dir)
&& let Ok(mut g) = s.lock()
{
g.artifacts_total = Some(total);
}
let mut cmd = Command::new("cargo");
cmd.current_dir(&project_dir);
cmd.args([
"build",
"--manifest-path",
manifest
.to_str()
.expect("Cargo.toml path must be valid UTF-8"),
"--bin",
"editor",
"--features",
"editor",
"--message-format=json-render-diagnostics",
]);
let editor_target_dir = match crate::new_project::jackdaw_dev_checkout() {
Some(checkout) => checkout.join("target").join("user-projects"),
None => project_dir.join("target"),
};
let editor_target_str = editor_target_dir
.to_str()
.expect("CARGO_TARGET_DIR path must be valid UTF-8");
cmd.env("CARGO_TARGET_DIR", editor_target_str);
run_cargo_with_progress(cmd, sink.as_ref())?;
let bin_name = if cfg!(target_os = "windows") {
"editor.exe"
} else {
"editor"
};
let bin = editor_target_dir.join("debug").join(bin_name);
if !bin.is_file() {
return Err(BuildError::OutputNotProduced { expected: bin });
}
Ok(bin)
}
fn run_cargo_with_progress(
mut cmd: Command,
sink: Option<&Arc<Mutex<BuildProgress>>>,
) -> Result<(), BuildError> {
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(BuildError::BuildSpawn)?;
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let stdout_sink = sink.cloned();
let stdout_handle = thread::spawn(move || {
let reader = BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) {
parse_json_line(&line, stdout_sink.as_ref());
}
});
let stderr_sink = sink.cloned();
let stderr_tail: Arc<Mutex<VecDeque<String>>> =
Arc::new(Mutex::new(VecDeque::with_capacity(LOG_TAIL_CAPACITY)));
let stderr_tail_for_thread = Arc::clone(&stderr_tail);
let stderr_handle = thread::spawn(move || {
let reader = BufReader::new(stderr);
for line in reader.lines().map_while(Result::ok) {
#[expect(
clippy::print_stderr,
reason = "tee child cargo stderr to user terminal"
)]
{
eprintln!("{line}");
}
if let Some(ref s) = stderr_sink
&& let Ok(mut g) = s.lock()
{
g.push_log(line.clone());
}
if let Ok(mut tail) = stderr_tail_for_thread.lock() {
if tail.len() >= LOG_TAIL_CAPACITY {
tail.pop_front();
}
tail.push_back(line);
}
}
});
let status = child.wait().map_err(BuildError::BuildSpawn)?;
let _ = stdout_handle.join();
let _ = stderr_handle.join();
if let Some(s) = sink
&& let Ok(mut g) = s.lock()
{
g.finished = true;
}
if !status.success() {
let tail = stderr_tail
.lock()
.map(|t| t.iter().cloned().collect::<Vec<_>>().join("\n"))
.unwrap_or_default();
return Err(BuildError::BuildFailed {
status,
stderr_tail: tail,
});
}
Ok(())
}
fn parse_json_line(line: &str, sink: Option<&Arc<Mutex<BuildProgress>>>) {
let Some(sink) = sink else { return };
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
return;
};
let reason = value.get("reason").and_then(|v| v.as_str()).unwrap_or("");
if reason == "compiler-artifact" {
let name = value
.get("target")
.and_then(|t| t.get("name"))
.and_then(|n| n.as_str())
.map(std::string::ToString::to_string);
if let Ok(mut g) = sink.lock() {
g.artifacts_done = g.artifacts_done.saturating_add(1);
if let Some(n) = name {
g.current_crate = Some(n);
}
}
} else if reason == "compiler-message" {
if let Some(rendered) = value
.get("message")
.and_then(|m| m.get("rendered"))
.and_then(|r| r.as_str())
&& let Ok(mut g) = sink.lock()
{
for l in rendered.lines().take(LOG_TAIL_CAPACITY) {
g.push_log(l.to_string());
}
}
}
}
fn estimate_total_artifacts(project_dir: &Path) -> Option<u32> {
let output = Command::new("cargo")
.current_dir(project_dir)
.args(["metadata", "--format-version=1"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
let packages = value.get("packages")?.as_array()?;
Some(packages.len() as u32)
}
pub fn cargo_clean_project(project_dir: &Path) -> Result<(), BuildError> {
let project_dir = project_dir
.canonicalize()
.map_err(|_| BuildError::NotADirectory(project_dir.to_path_buf()))?;
let manifest = project_dir.join("Cargo.toml");
if !manifest.is_file() {
return Err(BuildError::MissingCargoToml(project_dir));
}
let package_name = package_name_from_manifest(&project_dir);
let mut cmd = Command::new("cargo");
cmd.current_dir(&project_dir);
cmd.args([
"clean",
"--manifest-path",
manifest
.to_str()
.expect("Cargo.toml path must be valid UTF-8"),
"-p",
&package_name,
]);
let output = cmd.output().map_err(BuildError::BuildSpawn)?;
if !output.status.success() {
return Err(BuildError::BuildFailed {
status: output.status,
stderr_tail: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
Ok(())
}
fn package_name_from_manifest(project_dir: &Path) -> String {
std::fs::read_to_string(project_dir.join("Cargo.toml"))
.ok()
.and_then(|contents| {
contents.lines().find_map(|line| {
let trimmed = line.trim();
trimmed
.strip_prefix("name")
.and_then(|rest| rest.trim().strip_prefix('='))
.map(|rest| rest.trim().trim_matches('"').trim_matches('\'').to_owned())
})
})
.unwrap_or_else(|| "unnamed".to_string())
}
pub(crate) fn manifest_declares_cdylib(project_dir: &Path) -> bool {
let Ok(contents) = std::fs::read_to_string(project_dir.join("Cargo.toml")) else {
return false;
};
contents.lines().any(|line| {
let trimmed = line.trim();
let Some(rest) = trimmed.strip_prefix("crate-type") else {
return false;
};
let Some(rest) = rest.trim_start().strip_prefix('=') else {
return false;
};
rest.contains("\"cdylib\"") || rest.contains("'cdylib'")
})
}
pub(crate) fn artifact_file_name(project_dir: &Path) -> String {
let package_name = package_name_from_manifest(project_dir);
if cfg!(target_os = "windows") {
format!("{package_name}.dll")
} else if cfg!(target_os = "macos") {
format!("lib{package_name}.dylib")
} else {
format!("lib{package_name}.so")
}
}