This repository has been archived on 2026-10-29. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
Epiphany/epiphany-core/src/bin/epiphany-unity-bridge.rs
2026-05-06 07:55:02 +01:00

964 lines
34 KiB
Rust

use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use serde_json::json;
use std::collections::HashSet;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
const PROJECT_VERSION_RELATIVE: &str = "ProjectSettings/ProjectVersion.txt";
const UNITY_EDITOR_RELATIVE: &[&str] = &["Editor", "Unity.exe"];
const EDITOR_BRIDGE_RELATIVE: &str = "Assets/Editor/Epiphany/EpiphanyEditorBridge.cs";
const EDITOR_BRIDGE_EXECUTE_METHOD: &str = "GameCult.Epiphany.Unity.EpiphanyEditorBridge.RunProbe";
const FORBIDDEN_EXTRA_ARGS: &[&str] = &["-batchmode", "-quit", "-projectpath", "-logfile"];
fn main() -> Result<()> {
let mut args = env::args().skip(1);
let Some(command) = args.next() else {
return Err(anyhow!(
"usage: epiphany-unity-bridge <inspect|run|check-compilation|probe|run-tests|guidance>"
));
};
match run_command(&command, args.collect()) {
Ok((code, value)) => {
println!("{}", serde_json::to_string_pretty(&value)?);
std::process::exit(code);
}
Err(error) => {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"status": "error",
"error": error.to_string(),
}))?
);
std::process::exit(1);
}
}
}
fn run_command(command: &str, args: Vec<String>) -> Result<(i32, serde_json::Value)> {
let options = UnityOptions::parse(args)?;
let result = match command {
"inspect" => inspect_unity(&options)?,
"run" => run_unity(&options)?,
"check-compilation" => run_named_probe(&options, "check-compilation")?,
"probe" => {
let operation = options
.operation
.as_deref()
.ok_or_else(|| anyhow!("probe requires --operation"))?;
run_named_probe(&options, operation)?
}
"run-tests" => test_unity(&options)?,
"guidance" => json!({ "guidance": bridge_guidance(&options.project_path)? }),
_ => return Err(anyhow!("unsupported command: {command}")),
};
let blocked = result["runStatus"].as_str() == Some("blocked");
Ok((if blocked { 2 } else { 0 }, result))
}
#[derive(Debug)]
struct UnityOptions {
project_path: PathBuf,
artifact_root: PathBuf,
artifact_dir: Option<PathBuf>,
label: String,
timeout_seconds: u64,
dry_run: bool,
unity_args: Vec<String>,
operation: Option<String>,
scene: Option<String>,
asset: Option<String>,
guid: Option<String>,
max_objects: Option<u32>,
max_properties: Option<u32>,
max_dependencies: Option<u32>,
platform: String,
filter: Option<String>,
}
impl UnityOptions {
fn parse(args: Vec<String>) -> Result<Self> {
let root = repo_root()?;
let mut options = Self {
project_path: env::current_dir()?,
artifact_root: root.join(".epiphany-gui").join("runtime"),
artifact_dir: None,
label: "unity-bridge-run".to_string(),
timeout_seconds: 600,
dry_run: false,
unity_args: Vec::new(),
operation: None,
scene: None,
asset: None,
guid: None,
max_objects: None,
max_properties: None,
max_dependencies: None,
platform: "editmode".to_string(),
filter: None,
};
let mut iter = args.into_iter().peekable();
while let Some(arg) = iter.next() {
if arg == "--" {
options.unity_args.extend(iter);
break;
}
match arg.as_str() {
"--project-path" => options.project_path = next_path(&mut iter, &arg)?,
"--artifact-root" => options.artifact_root = next_path(&mut iter, &arg)?,
"--artifact-dir" => options.artifact_dir = Some(next_path(&mut iter, &arg)?),
"--label" => options.label = next_value(&mut iter, &arg)?,
"--timeout-seconds" => {
options.timeout_seconds = next_value(&mut iter, &arg)?.parse()?
}
"--dry-run" => options.dry_run = true,
"--operation" => options.operation = Some(next_value(&mut iter, &arg)?),
"--scene" => options.scene = Some(next_value(&mut iter, &arg)?),
"--asset" => options.asset = Some(next_value(&mut iter, &arg)?),
"--guid" => options.guid = Some(next_value(&mut iter, &arg)?),
"--max-objects" => {
options.max_objects = Some(next_value(&mut iter, &arg)?.parse()?)
}
"--max-properties" => {
options.max_properties = Some(next_value(&mut iter, &arg)?.parse()?)
}
"--max-dependencies" => {
options.max_dependencies = Some(next_value(&mut iter, &arg)?.parse()?)
}
"--platform" => options.platform = next_value(&mut iter, &arg)?,
"--filter" => options.filter = Some(next_value(&mut iter, &arg)?),
_ => options.unity_args.push(arg),
}
}
Ok(options)
}
}
fn next_value(iter: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
iter.next()
.ok_or_else(|| anyhow!("{name} requires a value"))
}
fn next_path(iter: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
Ok(PathBuf::from(next_value(iter, name)?))
}
fn repo_root() -> Result<PathBuf> {
Ok(Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.ok_or_else(|| anyhow!("epiphany-core has no parent repo root"))?
.to_path_buf())
}
fn now_iso() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
fn clean_windows_path(value: &str) -> String {
value.strip_prefix(r"\\?\").unwrap_or(value).to_string()
}
fn path_string(path: &Path) -> String {
clean_windows_path(&path.display().to_string())
}
fn maybe_resolve(path: &Path) -> String {
path_string(&path.canonicalize().unwrap_or_else(|_| path.to_path_buf()))
}
fn artifact_dir(root: &Path, action: &str) -> Result<PathBuf> {
let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
Ok(root
.canonicalize()
.unwrap_or_else(|_| root.to_path_buf())
.join(format!("unity-{action}-{nanos}-{}", std::process::id())))
}
fn write_json(path: &Path, value: &serde_json::Value) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, format!("{}\n", serde_json::to_string_pretty(value)?))
.with_context(|| format!("failed to write {}", path.display()))
}
fn write_text(path: &Path, value: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, value).with_context(|| format!("failed to write {}", path.display()))
}
fn read_unity_project_version(project_path: &Path) -> serde_json::Value {
let version_path = project_path.join(PROJECT_VERSION_RELATIVE);
let mut result = json!({
"path": path_string(&version_path),
"exists": version_path.exists(),
"editorVersion": null,
"editorVersionWithRevision": null,
});
if !version_path.exists() {
result["status"] = json!("missingProjectVersion");
result["note"] = json!("ProjectSettings/ProjectVersion.txt was not found.");
return result;
}
let Ok(raw) = fs::read_to_string(&version_path) else {
result["status"] = json!("missingProjectVersion");
result["note"] = json!("ProjectVersion.txt could not be read.");
return result;
};
for line in raw.lines() {
if let Some(value) = line.strip_prefix("m_EditorVersion:") {
result["editorVersion"] = json!(value.trim());
} else if let Some(value) = line.strip_prefix("m_EditorVersionWithRevision:") {
result["editorVersionWithRevision"] = json!(value.trim());
}
}
if result["editorVersion"]
.as_str()
.is_some_and(|value| !value.is_empty())
{
result["status"] = json!("ready");
} else {
result["status"] = json!("missingProjectVersion");
result["note"] = json!("ProjectVersion.txt did not contain m_EditorVersion.");
}
result
}
fn default_unity_editor_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
if let Ok(value) = env::var("EPIPHANY_UNITY_EDITOR_ROOTS") {
roots.extend(env::split_paths(&value));
}
roots.push(
PathBuf::from(env::var("ProgramFiles").unwrap_or_else(|_| r"C:\Program Files".to_string()))
.join("Unity")
.join("Hub")
.join("Editor"),
);
roots.push(
PathBuf::from(
env::var("ProgramFiles(x86)").unwrap_or_else(|_| r"C:\Program Files (x86)".to_string()),
)
.join("Unity")
.join("Hub")
.join("Editor"),
);
dedupe_paths(roots)
}
fn dedupe_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
let mut seen = HashSet::new();
let mut result = Vec::new();
for path in paths {
let key = maybe_resolve(&path).to_lowercase();
if seen.insert(key) {
result.push(path);
}
}
result
}
fn unity_editor_relative() -> PathBuf {
UNITY_EDITOR_RELATIVE.iter().collect()
}
fn editor_path_for_version(root: &Path, version: &str) -> PathBuf {
if root.file_name().and_then(|value| value.to_str()) == Some(version) {
root.join(unity_editor_relative())
} else {
root.join(version).join(unity_editor_relative())
}
}
fn installed_unity_editors(roots: &[PathBuf]) -> Result<Vec<serde_json::Value>> {
let mut editors = Vec::new();
let rel = unity_editor_relative();
for root in roots {
if !root.exists() {
continue;
}
let direct = root.join(&rel);
if direct.exists() {
editors.push(json!({
"version": root.file_name().and_then(|value| value.to_str()),
"root": path_string(root),
"editorPath": path_string(&direct),
}));
continue;
}
for entry in fs::read_dir(root)? {
let child = entry?.path();
if !child.is_dir() {
continue;
}
let editor = child.join(&rel);
if editor.exists() {
editors.push(json!({
"version": child.file_name().and_then(|value| value.to_str()),
"root": path_string(&child),
"editorPath": path_string(&editor),
}));
}
}
}
Ok(editors)
}
fn detect_editor_bridge(project_path: &Path) -> serde_json::Value {
let package_path = project_path.join(EDITOR_BRIDGE_RELATIVE);
json!({
"kind": "epiphanyUnityEditorBridge",
"path": path_string(&package_path),
"relativePath": EDITOR_BRIDGE_RELATIVE,
"exists": package_path.exists(),
"executeMethod": EDITOR_BRIDGE_EXECUTE_METHOD,
})
}
fn resolve_unity_editor(project_path: &Path) -> Result<serde_json::Value> {
let project_path = project_path
.canonicalize()
.unwrap_or_else(|_| project_path.to_path_buf());
let version = read_unity_project_version(&project_path);
let roots = default_unity_editor_roots();
let editors = installed_unity_editors(&roots)?;
let mut result = json!({
"kind": "unity",
"projectPath": path_string(&project_path),
"generatedAt": now_iso(),
"versionFile": version,
"searchRoots": roots.iter().map(|path| path_string(path)).collect::<Vec<_>>(),
"installedEditors": editors,
"projectVersion": null,
"projectVersionWithRevision": null,
"editorPath": null,
"status": "missingProjectVersion",
"note": "ProjectSettings/ProjectVersion.txt was not found or did not pin a Unity editor.",
"editorBridge": detect_editor_bridge(&project_path),
});
result["projectVersion"] = result["versionFile"]["editorVersion"].clone();
result["projectVersionWithRevision"] =
result["versionFile"]["editorVersionWithRevision"].clone();
let Some(project_version) = result["projectVersion"].as_str().map(ToOwned::to_owned) else {
return Ok(result);
};
if project_version.is_empty() {
return Ok(result);
}
let exact_candidates: Vec<PathBuf> = roots
.iter()
.map(|root| editor_path_for_version(root, &project_version))
.collect();
let exact = exact_candidates.iter().find(|path| path.exists());
result["candidatePaths"] = json!(
exact_candidates
.iter()
.map(|path| path_string(path))
.collect::<Vec<_>>()
);
if let Some(exact) = exact {
result["status"] = json!("ready");
result["editorPath"] = json!(path_string(exact));
result["note"] = json!(format!(
"Project pins Unity {project_version}; exact editor resolved at {}.",
path_string(exact)
));
} else {
let installed_versions = result["installedEditors"]
.as_array()
.into_iter()
.flatten()
.filter_map(|item| item["version"].as_str())
.collect::<Vec<_>>()
.join(", ");
result["status"] = json!("missingEditor");
result["note"] = json!(format!(
"Project pins Unity {project_version}, but no exact editor was found. Installed Hub versions: {}.",
if installed_versions.is_empty() {
"none detected"
} else {
&installed_versions
}
));
}
Ok(result)
}
fn render_inspection(summary: &serde_json::Value) -> String {
let installed_lines = summary["installedEditors"]
.as_array()
.filter(|items| !items.is_empty())
.map(|items| {
items
.iter()
.map(|item| {
format!(
"- {}: {}",
item["version"].as_str().unwrap_or("unknown"),
item["editorPath"].as_str().unwrap_or("unknown")
)
})
.collect::<Vec<_>>()
})
.unwrap_or_else(|| vec!["- none detected".to_string()]);
let candidate_lines = summary["candidatePaths"]
.as_array()
.filter(|items| !items.is_empty())
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str())
.map(|item| format!("- {item}"))
.collect::<Vec<_>>()
})
.unwrap_or_else(|| vec!["- none".to_string()]);
format!(
"# Unity Runtime Bridge\n\nStatus: {}\nProject: {}\nProject version: {}\nEditor path: {}\n\nInstalled Hub editors:\n{}\n\nExact candidate paths:\n{}\n\nNote: {}\n",
summary["status"].as_str().unwrap_or("unknown"),
summary["projectPath"].as_str().unwrap_or("unknown"),
summary["projectVersion"].as_str().unwrap_or("none"),
summary["editorPath"].as_str().unwrap_or("none"),
installed_lines.join("\n"),
candidate_lines.join("\n"),
summary["note"].as_str().unwrap_or(""),
)
}
fn write_inspection_artifacts(directory: &Path, summary: &serde_json::Value) -> Result<()> {
write_json(&directory.join("unity-bridge-summary.json"), summary)?;
write_text(
&directory.join("unity-bridge-inspection.md"),
&render_inspection(summary),
)
}
fn validate_unity_args(values: &[String]) -> Result<()> {
let lowered: HashSet<String> = values.iter().map(|value| value.to_lowercase()).collect();
let forbidden = FORBIDDEN_EXTRA_ARGS
.iter()
.filter(|value| lowered.contains(**value))
.copied()
.collect::<Vec<_>>();
if !forbidden.is_empty() {
return Err(anyhow!(
"Unity bridge owns batchmode, quit, projectPath, and logFile; remove extra args: {}",
forbidden.join(", ")
));
}
Ok(())
}
fn build_unity_command(
summary: &serde_json::Value,
extra_args: &[String],
log_path: Option<&Path>,
) -> Result<Vec<String>> {
let editor_path = summary["editorPath"]
.as_str()
.ok_or_else(|| anyhow!("Unity editor path is unavailable."))?;
let project_path = summary["projectPath"]
.as_str()
.ok_or_else(|| anyhow!("Unity project path is unavailable."))?;
validate_unity_args(extra_args)?;
let mut command = vec![
editor_path.to_string(),
"-batchmode".to_string(),
"-quit".to_string(),
"-projectPath".to_string(),
project_path.to_string(),
];
if let Some(log_path) = log_path {
command.extend(["-logFile".to_string(), path_string(log_path)]);
}
command.extend(extra_args.iter().cloned());
Ok(command)
}
fn editor_bridge_ready(summary: &serde_json::Value) -> bool {
summary["editorBridge"]["exists"].as_bool() == Some(true)
}
fn named_probe_extra_args(
options: &UnityOptions,
operation: &str,
directory: &Path,
) -> Vec<String> {
let mut args = vec![
"-executeMethod".to_string(),
EDITOR_BRIDGE_EXECUTE_METHOD.to_string(),
"-epiphanyArtifactDir".to_string(),
path_string(directory),
"-epiphanyOperation".to_string(),
operation.to_string(),
];
for (value, flag) in [
(
options.scene.as_ref().map(ToString::to_string),
"-epiphanyScene",
),
(
options.asset.as_ref().map(ToString::to_string),
"-epiphanyAsset",
),
(
options.guid.as_ref().map(ToString::to_string),
"-epiphanyGuid",
),
(
options.max_objects.map(|value| value.to_string()),
"-epiphanyMaxObjects",
),
(
options.max_properties.map(|value| value.to_string()),
"-epiphanyMaxProperties",
),
(
options.max_dependencies.map(|value| value.to_string()),
"-epiphanyMaxDependencies",
),
] {
if let Some(value) = value {
args.extend([flag.to_string(), value]);
}
}
args
}
fn expected_artifacts(operation: &str) -> Vec<&'static str> {
match operation {
"inspect-project" => vec![
"project-facts.json",
"unity-probe-result.json",
"unity-probe-result.md",
],
"check-compilation" => vec![
"compilation.json",
"unity-probe-result.json",
"unity-probe-result.md",
],
"scene-facts" => vec![
"scene-facts.json",
"unity-probe-result.json",
"unity-probe-result.md",
],
"prefab-facts" => vec![
"prefab-facts.json",
"unity-probe-result.json",
"unity-probe-result.md",
],
"serialized-object" => vec![
"serialized-object-facts.json",
"unity-probe-result.json",
"unity-probe-result.md",
],
"reference-search" => vec![
"reference-search.json",
"unity-probe-result.json",
"unity-probe-result.md",
],
"run-tests" => vec!["test-results.xml", "unity.log"],
_ => Vec::new(),
}
}
fn probe_description(operation: &str) -> &'static str {
match operation {
"inspect-project" => "Capture Unity project/editor/build/render-pipeline facts.",
"check-compilation" => {
"Check whether the editor bridge can run after Unity script compilation."
}
"scene-facts" => {
"Capture scene hierarchy, components, prefab links, and serialized fields."
}
"prefab-facts" => {
"Capture prefab hierarchy, nested instances, overrides, and serialized fields."
}
"serialized-object" => "Capture SerializedObject properties for one asset.",
"reference-search" => "Search Unity asset dependencies for references to a GUID or asset.",
_ => "Run a named Unity editor bridge probe.",
}
}
fn write_command_artifact(
directory: &Path,
command: &[String],
dry_run: bool,
operation: &str,
expected_artifacts: Vec<&str>,
) -> Result<()> {
write_json(
&directory.join("unity-command.json"),
&json!({
"command": command,
"dryRun": dry_run,
"operation": operation,
"expectedArtifacts": expected_artifacts,
}),
)
}
fn run_unity(options: &UnityOptions) -> Result<serde_json::Value> {
let directory = options
.artifact_dir
.clone()
.map(Ok)
.unwrap_or_else(|| artifact_dir(&options.artifact_root, "run"))?;
let project_path = options
.project_path
.canonicalize()
.unwrap_or_else(|_| options.project_path.clone());
let mut summary = resolve_unity_editor(&project_path)?;
summary["artifactPath"] = json!(path_string(&directory));
summary["operation"] = json!("run");
summary["label"] = json!(options.label);
summary["unityArgs"] = json!(options.unity_args);
if summary["status"].as_str() != Some("ready") {
summary["runStatus"] = json!("blocked");
summary["note"] = json!(format!(
"{} Runtime execution refused; install the exact pinned editor or use inspect artifacts as evidence of the missing runtime.",
summary["note"].as_str().unwrap_or("")
));
write_inspection_artifacts(&directory, &summary)?;
return Ok(summary);
}
let log_path = directory.join("unity.log");
let command = build_unity_command(&summary, &options.unity_args, Some(&log_path))?;
summary["command"] = json!(command);
summary["logPath"] = json!(path_string(&log_path));
if options.dry_run {
summary["runStatus"] = json!("planned");
summary["returncode"] = serde_json::Value::Null;
write_inspection_artifacts(&directory, &summary)?;
write_command_artifact(
&directory,
summary["command"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
.collect::<Vec<_>>()
.as_slice(),
true,
"run",
Vec::new(),
)?;
return Ok(summary);
}
execute_command(
&mut summary,
&directory,
&project_path,
options.timeout_seconds,
)?;
write_inspection_artifacts(&directory, &summary)?;
write_command_artifact(
&directory,
summary["command"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
.collect::<Vec<_>>()
.as_slice(),
false,
"run",
Vec::new(),
)?;
Ok(summary)
}
fn run_named_probe(options: &UnityOptions, operation: &str) -> Result<serde_json::Value> {
let allowed = [
"inspect-project",
"check-compilation",
"scene-facts",
"prefab-facts",
"serialized-object",
"reference-search",
];
if !allowed.contains(&operation) {
return Err(anyhow!("unsupported probe operation: {operation}"));
}
let directory =
options.artifact_dir.clone().map(Ok).unwrap_or_else(|| {
artifact_dir(&options.artifact_root, &operation.replace('-', "_"))
})?;
let project_path = options
.project_path
.canonicalize()
.unwrap_or_else(|_| options.project_path.clone());
let mut summary = resolve_unity_editor(&project_path)?;
summary["artifactPath"] = json!(path_string(&directory));
summary["operation"] = json!(operation);
summary["probe"] = json!({
"description": probe_description(operation),
"expectedArtifacts": expected_artifacts(operation),
});
if summary["status"].as_str() != Some("ready") {
summary["runStatus"] = json!("blocked");
summary["note"] = json!(format!(
"{} Runtime/editor probe refused; install the exact pinned editor or use inspect artifacts as evidence of the missing runtime.",
summary["note"].as_str().unwrap_or("")
));
write_inspection_artifacts(&directory, &summary)?;
return Ok(summary);
}
if !editor_bridge_ready(&summary) {
let bridge_path = summary["editorBridge"]["path"]
.as_str()
.map(ToOwned::to_owned)
.unwrap_or_else(|| path_string(&project_path.join(EDITOR_BRIDGE_RELATIVE)));
summary["status"] = json!("missingEditorBridgePackage");
summary["runStatus"] = json!("blocked");
summary["note"] = json!(format!(
"The Epiphany Unity editor package is missing. Add {bridge_path} before running editor-resident probes."
));
write_inspection_artifacts(&directory, &summary)?;
return Ok(summary);
}
let extra_args = named_probe_extra_args(options, operation, &directory);
let log_path = directory.join("unity.log");
let command = build_unity_command(&summary, &extra_args, Some(&log_path))?;
summary["command"] = json!(command);
summary["logPath"] = json!(path_string(&log_path));
if options.dry_run {
summary["runStatus"] = json!("planned");
summary["returncode"] = serde_json::Value::Null;
write_inspection_artifacts(&directory, &summary)?;
write_command_artifact(
&directory,
summary["command"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
.collect::<Vec<_>>()
.as_slice(),
true,
operation,
expected_artifacts(operation),
)?;
return Ok(summary);
}
execute_command(
&mut summary,
&directory,
&project_path,
options.timeout_seconds,
)?;
write_inspection_artifacts(&directory, &summary)?;
write_command_artifact(
&directory,
summary["command"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
.collect::<Vec<_>>()
.as_slice(),
false,
operation,
expected_artifacts(operation),
)?;
Ok(summary)
}
fn test_unity(options: &UnityOptions) -> Result<serde_json::Value> {
let directory = options
.artifact_dir
.clone()
.map(Ok)
.unwrap_or_else(|| artifact_dir(&options.artifact_root, "run_tests"))?;
let project_path = options
.project_path
.canonicalize()
.unwrap_or_else(|_| options.project_path.clone());
let mut summary = resolve_unity_editor(&project_path)?;
summary["artifactPath"] = json!(path_string(&directory));
summary["operation"] = json!("run-tests");
summary["testPlatform"] = json!(options.platform);
summary["testFilter"] = json!(options.filter);
if summary["status"].as_str() != Some("ready") {
summary["runStatus"] = json!("blocked");
summary["note"] = json!(format!(
"{} Unity tests refused; install the exact pinned editor or use inspect artifacts as evidence of the missing runtime.",
summary["note"].as_str().unwrap_or("")
));
write_inspection_artifacts(&directory, &summary)?;
return Ok(summary);
}
let log_path = directory.join("unity.log");
let test_results = directory.join("test-results.xml");
let mut extra_args = vec![
"-runTests".to_string(),
"-testPlatform".to_string(),
options.platform.clone(),
"-testResults".to_string(),
path_string(&test_results),
];
if let Some(filter) = &options.filter {
extra_args.extend(["-testFilter".to_string(), filter.clone()]);
}
let command = build_unity_command(&summary, &extra_args, Some(&log_path))?;
summary["command"] = json!(command);
summary["logPath"] = json!(path_string(&log_path));
summary["testResultsPath"] = json!(path_string(&test_results));
if options.dry_run {
summary["runStatus"] = json!("planned");
summary["returncode"] = serde_json::Value::Null;
write_inspection_artifacts(&directory, &summary)?;
write_command_artifact(
&directory,
summary["command"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
.collect::<Vec<_>>()
.as_slice(),
true,
"run-tests",
expected_artifacts("run-tests"),
)?;
return Ok(summary);
}
execute_command(
&mut summary,
&directory,
&project_path,
options.timeout_seconds,
)?;
write_inspection_artifacts(&directory, &summary)?;
write_command_artifact(
&directory,
summary["command"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
.collect::<Vec<_>>()
.as_slice(),
false,
"run-tests",
expected_artifacts("run-tests"),
)?;
Ok(summary)
}
fn execute_command(
summary: &mut serde_json::Value,
directory: &Path,
project_path: &Path,
timeout_seconds: u64,
) -> Result<()> {
let command = summary["command"]
.as_array()
.ok_or_else(|| anyhow!("summary command is not an array"))?
.iter()
.filter_map(|value| value.as_str().map(ToOwned::to_owned))
.collect::<Vec<_>>();
let (program, args) = command
.split_first()
.ok_or_else(|| anyhow!("empty Unity command"))?;
let stdout_path = directory.join("unity-stdout.log");
let stderr_path = directory.join("unity-stderr.log");
fs::create_dir_all(directory)?;
let started = Instant::now();
let mut child = Command::new(program)
.args(args)
.current_dir(project_path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
let timeout = Duration::from_secs(timeout_seconds);
loop {
if let Some(status) = child.try_wait()? {
let output = child.wait_with_output()?;
fs::write(&stdout_path, output.stdout)?;
fs::write(&stderr_path, output.stderr)?;
let code = status.code();
summary["runStatus"] = json!(if status.success() { "passed" } else { "failed" });
summary["returncode"] = json!(code);
summary["durationSeconds"] =
json!((started.elapsed().as_millis() as f64 / 1000.0).round());
summary["stdoutPath"] = json!(path_string(&stdout_path));
summary["stderrPath"] = json!(path_string(&stderr_path));
return Ok(());
}
if started.elapsed() > timeout {
let _ = child.kill();
let output = child.wait_with_output()?;
if !output.stdout.is_empty() {
fs::write(&stdout_path, output.stdout)?;
summary["stdoutPath"] = json!(path_string(&stdout_path));
}
if !output.stderr.is_empty() {
fs::write(&stderr_path, output.stderr)?;
summary["stderrPath"] = json!(path_string(&stderr_path));
}
summary["runStatus"] = json!("timedOut");
summary["returncode"] = serde_json::Value::Null;
summary["durationSeconds"] =
json!((started.elapsed().as_millis() as f64 / 1000.0).round());
summary["note"] = json!(format!(
"Unity command timed out after {timeout_seconds} seconds."
));
return Ok(());
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn inspect_unity(options: &UnityOptions) -> Result<serde_json::Value> {
let directory = options
.artifact_dir
.clone()
.map(Ok)
.unwrap_or_else(|| artifact_dir(&options.artifact_root, "inspect"))?;
let mut summary = resolve_unity_editor(&options.project_path)?;
summary["artifactPath"] = json!(path_string(&directory));
summary["operation"] = json!("inspect");
write_inspection_artifacts(&directory, &summary)?;
Ok(summary)
}
fn bridge_guidance(project_path: &Path) -> Result<String> {
let summary = resolve_unity_editor(project_path)?;
let inspect_command = format!(
"`epiphany-unity-bridge inspect --project-path {}`",
project_path.display()
);
if summary["status"].as_str() == Some("ready") {
if editor_bridge_ready(&summary) {
Ok(format!(
"- Unity bridge: project pins {} and the exact editor resolved to `{}`. If Unity execution is needed, use named bridge operations such as `epiphany-unity-bridge probe --project-path {} --operation scene-facts --scene <Assets/...unity>` or `epiphany-unity-bridge check-compilation --project-path {}`. The bridge owns -batchmode, -quit, -projectPath, -logFile, -executeMethod, and artifacts. Do not invoke `Unity`, `Unity.exe`, default installs, or PATH-resolved editors directly, and do not refactor Unity-owned scene/prefab state as raw text when the editor bridge can inspect it.",
summary["projectVersion"].as_str().unwrap_or("unknown"),
summary["editorPath"].as_str().unwrap_or("unknown"),
project_path.display(),
project_path.display(),
))
} else {
Ok(format!(
"- Unity bridge: project pins {} and the exact editor resolved to `{}`, but the resident editor package is missing. Add `{}` before claiming scene, prefab, or runtime facts. Do not invoke `Unity`, `Unity.exe`, default installs, or PATH-resolved editors directly.",
summary["projectVersion"].as_str().unwrap_or("unknown"),
summary["editorPath"].as_str().unwrap_or("unknown"),
EDITOR_BRIDGE_RELATIVE,
))
}
} else {
Ok(format!(
"- Unity bridge: {} Run {} to write an auditable runtime evidence artifact. Do not invoke `Unity`, `Unity.exe`, default installs, or PATH-resolved editors directly; if runtime parity is needed, stop with the bridge artifact as the evidence gap.",
summary["note"]
.as_str()
.unwrap_or("Unity bridge is blocked."),
inspect_command,
))
}
}