use anyhow::{Context, Result};
use cargo_plushie::{discover, doctor, download, generator, platform, scaffold};
use clap::{Args, Parser, Subcommand};
use std::path::{Path, PathBuf};
const BUILTIN_TYPE_NAMES: &[&str] = plushie_core::BUILTIN_TYPE_NAMES;
#[derive(Parser, Debug)]
#[command(
name = "cargo-plushie",
bin_name = "cargo",
about = "Cargo subcommand for Plushie renderer build + download",
version
)]
enum Cli {
Plushie(PlushieArgs),
}
#[derive(Args, Debug)]
struct PlushieArgs {
#[command(subcommand)]
command: PlushieSubcommand,
}
#[derive(Subcommand, Debug)]
enum PlushieSubcommand {
Build(BuildArgs),
Download(DownloadArgs),
Run(RunArgs),
NewWidget(NewWidgetArgs),
Init(InitArgs),
Doctor(DoctorArgs),
}
#[derive(Args, Debug)]
struct BuildArgs {
#[arg(long)]
release: bool,
#[arg(long)]
verbose: bool,
#[arg(long)]
manifest_path: Option<PathBuf>,
#[arg(long)]
wasm: bool,
#[arg(long)]
wasm_dir: Option<PathBuf>,
}
#[derive(Args, Debug)]
struct DownloadArgs {
#[arg(long)]
force: bool,
#[arg(long)]
manifest_path: Option<PathBuf>,
}
#[derive(Args, Debug)]
struct NewWidgetArgs {
name: String,
#[arg(long)]
path: Option<PathBuf>,
}
#[derive(Args, Debug)]
struct DoctorArgs {
#[arg(long)]
manifest_path: Option<PathBuf>,
}
#[derive(Args, Debug)]
struct InitArgs {
name: String,
#[arg(long)]
path: Option<PathBuf>,
}
#[derive(Args, Debug)]
struct RunArgs {
#[arg(long)]
watch: bool,
#[arg(long)]
release: bool,
#[arg(long)]
manifest_path: Option<PathBuf>,
}
fn main() -> Result<()> {
let mut argv: Vec<String> = std::env::args().collect();
if argv.len() >= 2 && argv[1] != "plushie" {
argv.insert(1, "plushie".to_string());
}
let cli = Cli::parse_from(argv);
let Cli::Plushie(args) = cli;
match args.command {
PlushieSubcommand::Build(b) => cmd_build(&b),
PlushieSubcommand::Download(d) => cmd_download(&d),
PlushieSubcommand::Run(r) => cmd_run(&r),
PlushieSubcommand::NewWidget(n) => cmd_new_widget(&n),
PlushieSubcommand::Init(i) => cmd_init(&i),
PlushieSubcommand::Doctor(d) => cmd_doctor(&d),
}
}
fn cmd_doctor(args: &DoctorArgs) -> Result<()> {
let manifest_dir = resolve_manifest_dir(args.manifest_path.as_ref())?;
let opts = doctor::DoctorOpts {
manifest_dir: &manifest_dir,
min_rustc_version: "1.92",
};
let report = doctor::run_doctor(&opts)?;
let mut stdout = std::io::stdout().lock();
doctor::write_report(&report, &mut stdout)?;
if report.critical {
std::process::exit(1);
}
Ok(())
}
fn cmd_init(args: &InitArgs) -> Result<()> {
let opts = scaffold::InitOpts {
name: &args.name,
path: args.path.as_deref(),
};
let result = scaffold::scaffold_app(&opts)?;
let shown = result
.crate_root
.strip_prefix(std::env::current_dir().unwrap_or_default())
.unwrap_or(&result.crate_root)
.display()
.to_string();
let shown = if shown.is_empty() {
result.crate_root.display().to_string()
} else {
shown
};
println!(
"Scaffolded {name} at {shown}.\n\nNext steps:\n \
cd {name}\n cargo run # direct mode\n \
cargo plushie run --watch # custom renderer + dev loop",
name = args.name,
);
Ok(())
}
fn cmd_new_widget(args: &NewWidgetArgs) -> Result<()> {
let opts = scaffold::NewWidgetOpts {
name: &args.name,
path: args.path.as_deref(),
builtin_type_names: BUILTIN_TYPE_NAMES,
};
let result = scaffold::scaffold_widget(&opts)?;
let relative = result
.crate_root
.strip_prefix(std::env::current_dir().unwrap_or_default())
.unwrap_or(&result.crate_root)
.display()
.to_string();
let shown = if relative.is_empty() {
result.crate_root.display().to_string()
} else {
relative
};
println!(
"Scaffolded {name} at {shown}. Add it to your app's \
[package.metadata.plushie].native_widgets or let auto-discovery \
pick it up via cargo plushie build.",
name = args.name,
);
Ok(())
}
fn resolve_manifest_dir(manifest_path: Option<&PathBuf>) -> Result<PathBuf> {
let path = match manifest_path {
Some(p) => p.clone(),
None => PathBuf::from("Cargo.toml"),
};
let abs = std::fs::canonicalize(&path)
.with_context(|| format!("manifest path `{}` not found", path.display()))?;
Ok(abs.parent().map(PathBuf::from).unwrap_or(abs))
}
fn target_dir(manifest_dir: &std::path::Path) -> PathBuf {
std::env::var_os("CARGO_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| manifest_dir.join("target"))
}
fn filter_native_widgets(
app_pkg: &cargo_metadata::Package,
discovered: &[cargo_plushie::WidgetMetadata],
allowlist: &[String],
) -> Result<Vec<cargo_plushie::WidgetMetadata>> {
use std::collections::HashSet;
let direct_deps: HashSet<&str> = app_pkg
.dependencies
.iter()
.map(|d| d.name.as_str())
.collect();
let discovered_by_name: std::collections::HashMap<&str, &cargo_plushie::WidgetMetadata> =
discovered
.iter()
.map(|w| (w.crate_name.as_str(), w))
.collect();
let mut out = Vec::with_capacity(allowlist.len());
for name in allowlist {
if !direct_deps.contains(name.as_str()) {
return Err(anyhow::anyhow!(
"[package.metadata.plushie].native_widgets lists `{name}`, but `{name}` \
is not a direct dependency of `{app}`. Add it to [dependencies] or remove \
it from the allowlist.",
app = app_pkg.name,
));
}
match discovered_by_name.get(name.as_str()) {
Some(widget) => out.push((*widget).clone()),
None => {
return Err(anyhow::anyhow!(
"[package.metadata.plushie].native_widgets lists `{name}`, but that \
crate does not declare `[package.metadata.plushie.widget]`. Either \
remove it from the allowlist or add the widget metadata table to \
the crate's Cargo.toml."
));
}
}
}
out.sort_by(|a, b| a.crate_name.cmp(&b.crate_name));
Ok(out)
}
fn cmd_build(args: &BuildArgs) -> Result<()> {
let manifest_dir = resolve_manifest_dir(args.manifest_path.as_ref())?;
if args.wasm {
return cmd_build_wasm(&manifest_dir, args);
}
let target = target_dir(&manifest_dir);
let output_dir = target.join("plushie-renderer");
std::fs::create_dir_all(&output_dir)?;
let metadata = cargo_metadata::MetadataCommand::new()
.manifest_path(manifest_dir.join("Cargo.toml"))
.no_deps()
.exec()
.with_context(|| "cargo metadata (no-deps) failed")?;
let root_id = metadata
.resolve
.as_ref()
.and_then(|r| r.root.as_ref())
.cloned();
let app_pkg = match root_id {
Some(id) => metadata.packages.iter().find(|p| p.id == id).cloned(),
None => metadata.packages.first().cloned(),
};
let app_pkg = app_pkg.ok_or_else(|| anyhow::anyhow!("no root package in cargo metadata"))?;
let binary_name_override = app_pkg
.metadata
.get("plushie")
.and_then(|v| v.get("binary_name"))
.and_then(|v| v.as_str())
.map(str::to_string);
let native_widgets_override: Vec<String> = app_pkg
.metadata
.get("plushie")
.and_then(|v| v.get("native_widgets"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let source_path_env = std::env::var_os("PLUSHIE_RUST_SOURCE_PATH").map(PathBuf::from);
let source_path_meta = app_pkg
.metadata
.get("plushie")
.and_then(|v| v.get("source_path"))
.and_then(|v| v.as_str())
.map(PathBuf::from);
let source_path = source_path_env.or(source_path_meta).and_then(|p| {
std::fs::canonicalize(&p).ok().or_else(|| {
eprintln!(
"warning: plushie source_path `{}` does not exist; ignoring",
p.display()
);
None
})
});
if let Some(source) = &source_path {
cargo_plushie::patch_config::write_scratch_cargo_config(&manifest_dir, source)?;
}
let discovered = discover::discover_widgets(&manifest_dir)?;
let widgets = if native_widgets_override.is_empty() {
discovered
} else {
filter_native_widgets(&app_pkg, &discovered, &native_widgets_override)?
};
discover::check_all_collisions(&widgets, BUILTIN_TYPE_NAMES)?;
let effective_version = app_pkg.version.to_string();
let config = generator::WorkspaceConfig {
app_manifest_dir: &manifest_dir,
output_dir: &output_dir,
binary_name: binary_name_override,
app_name: &app_pkg.name,
workspace_version: &effective_version,
source_path,
widgets: &widgets,
};
generator::generate_workspace(&config)?;
let mut cmd =
std::process::Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()));
cmd.current_dir(&output_dir).arg("build");
if args.release {
cmd.arg("--release");
}
if args.verbose {
cmd.arg("--verbose");
eprintln!(
"running: cargo build{}",
if args.release { " --release" } else { "" }
);
}
let status = cmd.status().with_context(|| "failed to run cargo build")?;
if !status.success() {
return Err(cargo_plushie::Error::CargoBuildFailed(status).into());
}
println!(
"plushie: generated renderer workspace at {} ({} widgets registered)",
output_dir.display(),
widgets.len()
);
Ok(())
}
fn cmd_build_wasm(manifest_dir: &Path, args: &BuildArgs) -> Result<()> {
if std::process::Command::new("wasm-pack")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| !s.success())
.unwrap_or(true)
{
return Err(anyhow::anyhow!(
"`wasm-pack` not found on PATH. Install it with \
`cargo install wasm-pack` or `curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh`."
));
}
let crate_dir = resolve_wasm_crate_dir(manifest_dir)?;
let target = target_dir(manifest_dir);
let output_dir = args
.wasm_dir
.clone()
.unwrap_or_else(|| target.join("plushie/pkg"));
std::fs::create_dir_all(&output_dir)?;
let wasm_target_dir = target.join("plushie/wasm-target");
let mut cmd = std::process::Command::new("wasm-pack");
cmd.arg("build")
.arg(&crate_dir)
.args(["--target", "web"])
.args(["--out-dir", &output_dir.display().to_string()])
.env("CARGO_TARGET_DIR", &wasm_target_dir);
if args.release {
cmd.arg("--release");
} else {
cmd.arg("--dev");
}
if args.verbose {
eprintln!(
"running: wasm-pack build {crate_dir} --target web --out-dir {out}{profile}",
crate_dir = crate_dir.display(),
out = output_dir.display(),
profile = if args.release { " --release" } else { " --dev" },
);
}
let status = cmd.status().with_context(|| "failed to run wasm-pack")?;
if !status.success() {
return Err(cargo_plushie::Error::CargoBuildFailed(status).into());
}
println!("plushie: wasm bundle generated at {}", output_dir.display());
Ok(())
}
fn resolve_wasm_crate_dir(manifest_dir: &Path) -> Result<PathBuf> {
if let Some(env) = std::env::var_os("PLUSHIE_RUST_SOURCE_PATH") {
let path = PathBuf::from(env);
let root = std::fs::canonicalize(&path).with_context(|| {
format!(
"PLUSHIE_RUST_SOURCE_PATH `{}` does not exist",
path.display()
)
})?;
return Ok(root.join("crates/plushie-renderer-wasm"));
}
let metadata = cargo_metadata::MetadataCommand::new()
.manifest_path(manifest_dir.join("Cargo.toml"))
.no_deps()
.exec()
.with_context(|| "cargo metadata (no-deps) failed")?;
let app_pkg = metadata
.resolve
.as_ref()
.and_then(|r| r.root.as_ref())
.and_then(|id| metadata.packages.iter().find(|p| &p.id == id))
.or_else(|| metadata.packages.first());
if let Some(pkg) = app_pkg
&& let Some(meta_path) = pkg
.metadata
.get("plushie")
.and_then(|v| v.get("source_path"))
.and_then(|v| v.as_str())
{
let resolved = manifest_dir.join(meta_path);
if let Ok(abs) = std::fs::canonicalize(&resolved) {
return Ok(abs.join("crates/plushie-renderer-wasm"));
}
}
let sibling = manifest_dir.join("..");
if sibling.join("crates/plushie-renderer-wasm").is_dir() {
let abs = std::fs::canonicalize(&sibling).unwrap_or(sibling);
return Ok(abs.join("crates/plushie-renderer-wasm"));
}
let scratch_dir = target_dir(manifest_dir).join("plushie-wasm-scratch");
fetch_wasm_crate_from_registry(&scratch_dir)
}
fn fetch_wasm_crate_from_registry(scratch_dir: &Path) -> Result<PathBuf> {
let version = env!("CARGO_PKG_VERSION");
std::fs::create_dir_all(scratch_dir)?;
generator::write_if_changed(
&scratch_dir.join("Cargo.toml"),
&format!(
"# Auto-generated by cargo-plushie. Do not edit.\n\
[package]\nname = \"plushie-wasm-scratch\"\nversion = \"0.0.1\"\nedition = \"2024\"\n\n\
[dependencies]\nplushie-renderer-wasm = \"{version}\"\n"
),
)?;
let metadata = cargo_metadata::MetadataCommand::new()
.manifest_path(scratch_dir.join("Cargo.toml"))
.exec()
.with_context(|| {
format!("failed to fetch plushie-renderer-wasm {version} from crates.io")
})?;
let pkg = metadata
.packages
.iter()
.find(|p| p.name == "plushie-renderer-wasm")
.ok_or_else(|| {
anyhow::anyhow!("plushie-renderer-wasm {version} not found in registry after fetch")
})?;
let crate_dir = pkg
.manifest_path
.parent()
.ok_or_else(|| anyhow::anyhow!("unexpected manifest_path for plushie-renderer-wasm"))?
.to_path_buf()
.into_std_path_buf();
Ok(crate_dir)
}
fn cmd_download(args: &DownloadArgs) -> Result<()> {
let manifest_dir = resolve_manifest_dir(args.manifest_path.as_ref())?;
let target = target_dir(&manifest_dir);
let widgets = discover::discover_widgets(&manifest_dir)?;
download::refuse_if_native_widgets(&widgets)?;
let metadata = cargo_metadata::MetadataCommand::new()
.manifest_path(manifest_dir.join("Cargo.toml"))
.exec()
.with_context(|| "cargo metadata failed")?;
let version = metadata
.packages
.iter()
.find(|p| p.name == "plushie-renderer-lib")
.map(|p| p.version.to_string())
.or_else(|| {
metadata
.packages
.iter()
.find(|p| p.name == "plushie")
.map(|p| p.version.to_string())
})
.ok_or_else(|| {
anyhow::anyhow!(
"unable to determine renderer version: neither `plushie-renderer-lib` \
nor `plushie` appears in the dep graph"
)
})?;
let dl_target = download::DownloadTarget::new(&target, &version);
println!(
"plushie: resolved download platform as {}-{}",
platform::os_name(),
platform::arch_name()
);
if dl_target.binary_path.exists() && !args.force {
println!(
"plushie: binary already present at {}; pass --force to re-download",
dl_target.binary_path.display()
);
return Ok(());
}
println!("plushie: downloading {}", dl_target.binary_url);
let bytes = download::fetch_bytes(&dl_target.binary_url)?;
let sidecar_bytes = download::fetch_bytes(&dl_target.sha256_url)?;
let sidecar_str =
std::str::from_utf8(&sidecar_bytes).context("sha256 sidecar was not UTF-8")?;
download::verify_sha256(&bytes, sidecar_str)?;
download::install_binary(&dl_target, &bytes, sidecar_str)?;
println!(
"plushie: installed renderer at {}",
dl_target.binary_path.display()
);
Ok(())
}
fn cmd_run(args: &RunArgs) -> Result<()> {
let manifest_dir = resolve_manifest_dir(args.manifest_path.as_ref())?;
let build = BuildArgs {
release: args.release,
verbose: false,
manifest_path: args.manifest_path.clone(),
wasm: false,
wasm_dir: None,
};
cmd_build(&build)?;
let pinned = resolve_built_binary(&manifest_dir, args)?;
let pinned = pinned.is_file().then_some(pinned);
if args.watch && cargo_watch_available() {
run_with_cargo_watch(&manifest_dir, args, pinned.as_deref())
} else if args.watch {
eprintln!(
"plushie: `cargo-watch` not found; install with `cargo install cargo-watch` \
for --watch, falling back to single `cargo run`"
);
run_cargo_run(&manifest_dir, args, pinned.as_deref())
} else {
run_cargo_run(&manifest_dir, args, pinned.as_deref())
}
}
fn resolve_built_binary(manifest_dir: &Path, args: &RunArgs) -> Result<PathBuf> {
let metadata = cargo_metadata::MetadataCommand::new()
.manifest_path(manifest_dir.join("Cargo.toml"))
.no_deps()
.exec()
.with_context(|| "cargo metadata (no-deps) failed")?;
let root_id = metadata
.resolve
.as_ref()
.and_then(|r| r.root.as_ref())
.cloned();
let app_pkg = match root_id {
Some(id) => metadata.packages.iter().find(|p| p.id == id).cloned(),
None => metadata.packages.first().cloned(),
};
let app_pkg = app_pkg.ok_or_else(|| anyhow::anyhow!("no root package in cargo metadata"))?;
let binary_name_override = app_pkg
.metadata
.get("plushie")
.and_then(|v| v.get("binary_name"))
.and_then(|v| v.as_str())
.map(str::to_string);
let bin_name = binary_name_override
.unwrap_or_else(|| format!("{}-renderer", app_pkg.name.replace('_', "-")));
let profile_dir = if args.release { "release" } else { "debug" };
let target = target_dir(manifest_dir);
let binary = target
.join("plushie-renderer/target")
.join(profile_dir)
.join(if cfg!(windows) {
format!("{bin_name}.exe")
} else {
bin_name
});
Ok(binary)
}
fn cargo_watch_available() -> bool {
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
std::process::Command::new(cargo)
.args(["watch", "--version"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn run_cargo_run(
manifest_dir: &std::path::Path,
args: &RunArgs,
pinned: Option<&Path>,
) -> Result<()> {
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let mut cmd = std::process::Command::new(cargo);
cmd.current_dir(manifest_dir).arg("run");
if args.release {
cmd.arg("--release");
}
if let Some(path) = pinned {
cmd.env("PLUSHIE_BINARY_PATH", path);
}
let status = cmd.status().with_context(|| "failed to run cargo run")?;
if !status.success() {
return Err(cargo_plushie::Error::CargoBuildFailed(status).into());
}
Ok(())
}
fn run_with_cargo_watch(
manifest_dir: &std::path::Path,
args: &RunArgs,
pinned: Option<&Path>,
) -> Result<()> {
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let profile = if args.release { " --release" } else { "" };
let shell_cmd = format!("cargo plushie build{profile} && cargo run{profile}");
let mut cmd = std::process::Command::new(cargo);
cmd.current_dir(manifest_dir)
.args(["watch", "-w", "src", "-s", &shell_cmd]);
if let Some(path) = pinned {
cmd.env("PLUSHIE_BINARY_PATH", path);
}
let status = cmd.status().with_context(|| "failed to run cargo watch")?;
if !status.success() {
return Err(cargo_plushie::Error::CargoBuildFailed(status).into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::BUILTIN_TYPE_NAMES;
#[test]
fn builtin_type_names_come_from_core() {
assert!(std::ptr::eq(
BUILTIN_TYPE_NAMES.as_ptr(),
plushie_core::BUILTIN_TYPE_NAMES.as_ptr()
));
}
}