use std::process::{Command, ExitCode};
use unity_solution_generator::{
BuildConfig, BuildPlatform, DEFAULT_GENERATOR_ROOT, DllRef, GenerateOptions, LockfileIO,
SolutionGenerator, TypecheckOptions, lockfile_path, resolve_project_root,
typecheck::run as typecheck_run,
};
fn main() -> ExitCode {
init_tracing();
let mut args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() || args.iter().any(|a| a == "--help" || a == "-h") {
print_usage();
return ExitCode::SUCCESS;
}
match args.first().map(String::as_str) {
Some("lock") => {
args.remove(0);
run_lock(&args)
}
Some("generate") => {
args.remove(0);
run_generate(&args)
}
Some("typecheck") => {
args.remove(0);
run_typecheck(&args)
}
Some("build") => {
args.remove(0);
run_build(&args)
}
Some(other) => {
die(&format!(
"Unknown command '{}'. Use 'lock', 'generate', 'typecheck', or 'build'.",
other
));
}
None => unreachable!(),
}
}
fn run_lock(args: &[String]) -> ExitCode {
let unity_root = args
.first()
.filter(|a| !a.starts_with("--"))
.map(String::as_str)
.unwrap_or(".");
let resolved = resolve_project_root(unity_root);
match LockfileIO::scan_and_write(&resolved, DEFAULT_GENERATOR_ROOT) {
Ok(lockfile) => {
println!("Locked csproj.lock:");
println!(
" Unity {} ({})",
lockfile.unity_version, lockfile.unity_path
);
println!(
" {} DLL references, {} analyzers",
lockfile.total_ref_count(),
lockfile.analyzers.len()
);
println!(
" {} defines, {} scripting defines",
lockfile.defines.len(),
lockfile.defines_scripting.len()
);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("error: {}", e);
ExitCode::from(1)
}
}
}
fn run_generate(args: &[String]) -> ExitCode {
match generate_sln(args, "generate") {
Ok(sln_path) => {
println!("{}", sln_path);
ExitCode::SUCCESS
}
Err(code) => code,
}
}
fn run_build(args: &[String]) -> ExitCode {
let (gen_args, dotnet_args): (&[String], &[String]) =
match args.iter().position(|a| a == "--") {
Some(i) => (&args[..i], &args[i + 1..]),
None => (args, &[]),
};
let sln_path = match generate_sln(gen_args, "build") {
Ok(p) => p,
Err(code) => return code,
};
let default_args = ["-v:q".to_string()];
let dotnet_args: &[String] = if dotnet_args.is_empty() { &default_args } else { dotnet_args };
eprintln!("dotnet build {} {}", sln_path, dotnet_args.join(" "));
match Command::new("dotnet").arg("build").arg(&sln_path).args(dotnet_args).status() {
Ok(s) if s.success() => ExitCode::SUCCESS,
Ok(s) => ExitCode::from(s.code().unwrap_or(1).clamp(1, 255) as u8),
Err(e) => {
eprintln!("error: failed to spawn `dotnet`: {}", e);
ExitCode::from(1)
}
}
}
fn generate_sln(args: &[String], cmd_name: &str) -> Result<String, ExitCode> {
let (project_root, rest) = split_root_arg(args);
if rest.len() < 2 {
die(&format!(
"{cmd_name} requires: [<unity-root>] <platform> <config> [options]"
));
}
let Some(platform) = BuildPlatform::parse(&rest[0]) else {
die(&format!(
"Unknown platform '{}'. Use 'ios', 'android', or 'osx'.",
rest[0]
));
};
let Some(build_config) = BuildConfig::parse(&rest[1]) else {
die(&format!(
"Unknown config '{}'. Use 'prod', 'dev', or 'editor'.",
rest[1]
));
};
let mut extra_refs_raw: Option<String> = None;
let mut i = 2;
while i < rest.len() {
match rest[i].as_str() {
"--extra-refs" => {
i += 1;
if i >= rest.len() {
die("--extra-refs requires a comma-separated list of DLL paths");
}
extra_refs_raw = Some(rest[i].clone());
}
other => die(&format!("Unknown option: {}", other)),
}
i += 1;
}
let resolved = resolve_project_root(&project_root);
let extra_refs = extra_refs_raw
.as_deref()
.map(DllRef::parse_list)
.unwrap_or_default();
let options = GenerateOptions::new(resolved.clone(), platform)
.with_build_config(build_config)
.with_extra_refs(extra_refs);
let lockfile_p = lockfile_path(&resolved, DEFAULT_GENERATOR_ROOT);
let lockfile = if std::path::Path::new(&lockfile_p).exists() {
match LockfileIO::read(&lockfile_p) {
Ok(l) => l,
Err(e) => {
eprintln!("error: {}", e);
return Err(ExitCode::from(1));
}
}
} else {
eprintln!("No lockfile found, running lock...");
match LockfileIO::scan_and_write(&resolved, DEFAULT_GENERATOR_ROOT) {
Ok(l) => {
eprintln!("Locked: {}", l.unity_version);
l
}
Err(e) => {
eprintln!("error: {}", e);
return Err(ExitCode::from(1));
}
}
};
match SolutionGenerator::new().generate_from_lockfile(&options, &lockfile) {
Ok(r) => {
for w in r.warnings {
eprintln!("warning: {}", w);
}
Ok(r.variant_sln_path)
}
Err(e) => {
eprintln!("error: {}", e);
Err(ExitCode::from(1))
}
}
}
fn run_typecheck(args: &[String]) -> ExitCode {
let (project_root, rest) = split_root_arg(args);
let platform = if !rest.is_empty() {
match BuildPlatform::parse(&rest[0]) {
Some(p) => p,
None => die(&format!(
"Unknown platform '{}'. Use 'ios', 'android', or 'osx'.",
rest[0]
)),
}
} else {
BuildPlatform::Ios
};
let build_config = if rest.len() > 1 {
match BuildConfig::parse(&rest[1]) {
Some(c) => c,
None => die(&format!(
"Unknown config '{}'. Use 'prod', 'dev', or 'editor'.",
rest[1]
)),
}
} else {
BuildConfig::Editor
};
let mut extra_refs_raw: Option<String> = None;
let args = rest;
let mut i = if args.len() > 1 { 2 } else { args.len() };
while i < args.len() {
match args[i].as_str() {
"--extra-refs" => {
i += 1;
if i >= args.len() {
die("--extra-refs requires a comma-separated list of DLL paths");
}
extra_refs_raw = Some(args[i].clone());
}
other => die(&format!("Unknown option: {}", other)),
}
i += 1;
}
let resolved = resolve_project_root(&project_root);
let extra_refs = extra_refs_raw
.as_deref()
.map(DllRef::parse_list)
.unwrap_or_default();
let opts = TypecheckOptions::new(resolved, platform)
.with_build_config(build_config)
.with_extra_refs(extra_refs);
match typecheck_run(&opts) {
Ok(result) => {
if result.ok() {
eprintln!(
"ok: {} recompiled, {} skipped",
result.recompiled, result.skipped
);
ExitCode::SUCCESS
} else {
for (name, msg) in &result.failures {
eprintln!("=== {} ===", name);
eprintln!("{}", msg);
}
eprintln!(
"FAILED: {}/{} project(s) ({})",
result.failures.len(),
result.failures.len() + result.recompiled + result.skipped,
result
.failures
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
);
ExitCode::from(1)
}
}
Err(e) => {
eprintln!("error: {}", e);
ExitCode::from(1)
}
}
}
fn split_root_arg(args: &[String]) -> (String, &[String]) {
match args.first() {
Some(first)
if !first.starts_with("--") && BuildPlatform::parse(first).is_none() =>
{
(first.clone(), &args[1..])
}
_ => (".".to_string(), args),
}
}
fn die(msg: &str) -> ! {
eprintln!("error: {}", msg);
std::process::exit(1);
}
fn init_tracing() {
use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt;
let level = match std::env::var("USG_PROFILE").ok().as_deref() {
None | Some("") | Some("0") => return,
Some("full") => "trace",
_ => "info",
};
let filter =
EnvFilter::try_from_env("USG_LOG").unwrap_or_else(|_| EnvFilter::new(format!("unity_solution_generator={level}")));
fmt()
.with_target(false)
.with_writer(std::io::stderr)
.with_span_events(fmt::format::FmtSpan::CLOSE)
.with_env_filter(filter)
.init();
}
fn print_usage() {
println!(
"USAGE:
unity-solution-generator lock [<unity-root>]
unity-solution-generator generate [<unity-root>] <platform> <config> [options]
unity-solution-generator typecheck [<unity-root>] [<platform>] [<config>] [options]
unity-solution-generator build [<unity-root>] <platform> <config> [options] [-- <dotnet-build-args>...]
When <unity-root> is omitted, climbs from the current directory to the
nearest ancestor containing `ProjectSettings/ProjectVersion.txt`.
`typecheck` also defaults platform=ios, config=editor.
COMMANDS:
lock Scan Unity installation and project to generate csproj.lock
generate Regenerate .csproj/.sln for a platform+config variant
typecheck Validate compile via direct csc.dll invocation
(no MSBuild). Used by Hot Reload pre-flight in
meow-tower's justfile.
build Run `dotnet build` on the generated .sln. Args after
`--` are forwarded verbatim (defaults to `-v:q`).
ARGUMENTS:
unity-root Unity project root (defaults to climbing from CWD)
platform ios | android | osx
config prod | dev | editor
OPTIONS:
--extra-refs <paths> Comma-separated absolute paths to additional DLLs
-h, --help Show help"
);
}