diff --git a/CHANGELOG.md b/CHANGELOG.md index ac3c7e6..5ea9440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add the `dusk-forge` CLI with `new`, `build`, `test`, and `check` commands for contract project scaffolding and workflows. + +### Changed + +- Make local forge path overrides opt-in for release builds and harden CLI template/path handling across platforms. + +### Fixed + +- Make `dusk-forge build data-driver` select the supported project feature (`data-driver-js` or `data-driver`) instead of hardcoding the JS variant. + ## [0.2.2] - 2026-02-02 ### Changed diff --git a/Cargo.toml b/Cargo.toml index 2da589d..ad7fe1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ dusk-core = { workspace = true } [workspace] resolver = "2" -members = ["contract-macro", "tests/types", "tests/test-bridge"] +members = ["contract-macro", "tests/types", "tests/test-bridge", "cli"] exclude = ["contract-template"] [workspace.package] @@ -45,6 +45,16 @@ serde_json = { version = "1", default-features = false } proc-macro2 = "1" quote = "1" syn = { version = "2", features = ["full", "visit"] } +# Keep this on a pre-edition-2024 release until the workspace toolchain moves +# forward; newer releases require a newer Cargo parser than this branch uses. +assert_cmd = "=2.1.1" +cargo_metadata = "0.19" +clap = { version = "4", features = ["derive"] } +colored = "3" +predicates = "3" +tempfile = "=3.10.1" +thiserror = "2" +toml = "0.8" # Pin to match L1Contracts versions dusk-vm = { version = "=1.4.0", default-features = false } @@ -53,6 +63,7 @@ blake2b_simd = "=1.0.2" blake3 = "=1.5.4" constant_time_eq = "=0.3.1" time-core = "=0.1.6" +uuid = "=1.20.0" # Enable overflow checks in release builds for contract safety [profile.release] diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..38416b1 --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "dusk-forge-cli" +version = "0.1.0" +edition = "2021" +description = "CLI for scaffolding and building Dusk Forge contracts" +license = "MPL-2.0" +repository = "https://github.com/dusk-network/forge" + +[[bin]] +name = "dusk-forge" +path = "src/main.rs" + +[dependencies] +cargo_metadata = { workspace = true } +clap = { workspace = true, features = ["derive"] } +colored = { workspace = true } +serde = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } +toml = { workspace = true } + +[dev-dependencies] +assert_cmd = { workspace = true } +predicates = { workspace = true } +tempfile = { workspace = true } diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..13d6e4c --- /dev/null +++ b/cli/README.md @@ -0,0 +1,99 @@ +# dusk-forge CLI + +`dusk-forge` is the command-line interface for scaffolding and working with Dusk Forge smart contracts. + +Naming note: +- Cargo package name: `dusk-forge-cli` +- Installed executable: `dusk-forge` + +## Install + +Install from this local repo: + +```bash +cargo install --path cli +``` + +Install from Git (after this is pushed): + +```bash +cargo install --git https://github.com/dusk-network/forge dusk-forge-cli +``` + +Both commands install the `dusk-forge` binary into `~/.cargo/bin`. Ensure that directory is on your `PATH`. + +## Build + +From the workspace root: + +```bash +cargo build -p dusk-forge-cli +``` + +Run directly: + +```bash +cargo run -p dusk-forge-cli -- --help +``` + +## Commands + +- `dusk-forge new `: scaffold a new contract project. +- `dusk-forge build [target]`: build WASM artifacts. Targets: `all` (default), `contract`, `data-driver`. +- `dusk-forge test [-- ]`: build contract WASM and run `cargo test --release`. +- `dusk-forge check`: validate project structure and toolchain. + +## Common Options + +Project-scoped commands support: + +- `--path `: contract project directory (defaults to current directory). +- `--verbose`: print executed command details. + +## Examples + +Create and build a project: + +```bash +dusk-forge new my-counter --no-git +cd my-counter + +dusk-forge check +dusk-forge build +dusk-forge build contract +dusk-forge test +``` + +## Toolchain Requirements + +Contract builds require: + +- The toolchain configured in `rust-toolchain.toml` (generated by `dusk-forge new`) +- `wasm32-unknown-unknown` target for that toolchain +- `rust-src` component for that toolchain +- No implicit fallback toolchain is used; if `rust-toolchain.toml` is missing, commands fail. + +Data-driver builds require: + +- The toolchain configured in `rust-toolchain.toml` +- `wasm32-unknown-unknown` target for that toolchain + +Optional tools: + +- `wasm-opt` for smaller WASM artifacts + +## Template Notes + +`new` supports built-in templates: + +- `--template counter` (default) +- `--template empty` + +Contract name validation accepts lowercase kebab-case names (`[a-z0-9-]`, must start with a letter). + +Scaffolded projects include: + +- `rust-toolchain.toml` for deterministic toolchain selection +- `Cargo.lock` generated at scaffold time + +Build/test commands run Cargo with `--locked`. diff --git a/cli/src/build_runner/mod.rs b/cli/src/build_runner/mod.rs new file mode 100644 index 0000000..51041eb --- /dev/null +++ b/cli/src/build_runner/mod.rs @@ -0,0 +1,193 @@ +pub mod wasm_opt; + +use std::{ + env, + ffi::OsStr, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +use crate::{ + error::{CliError, Result}, + project::detect, + project::metadata::ProjectMetadata, + toolchain::{self, WASM_TARGET}, +}; + +const CONTRACT_FEATURE: &str = "contract"; +const STACK_SIZE: u32 = 65_536; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BuildTarget { + Contract, + DataDriver, +} + +impl BuildTarget { + pub fn label(self) -> &'static str { + match self { + Self::Contract => "contract", + Self::DataDriver => "data-driver", + } + } + + pub fn wasm_path(self, project: &ProjectMetadata) -> PathBuf { + match self { + Self::Contract => project.contract_wasm_path.clone(), + Self::DataDriver => project.data_driver_wasm_path.clone(), + } + } +} + +pub fn build(project: &ProjectMetadata, target: BuildTarget, verbose: bool) -> Result { + let mut cmd = Command::new("cargo"); + let toolchain_arg = toolchain::cargo_toolchain_arg(&project.project_dir)?; + let feature = match target { + BuildTarget::Contract => CONTRACT_FEATURE, + BuildTarget::DataDriver => detect::resolve_data_driver_feature(&project.project_dir)?, + }; + + cmd.arg(&toolchain_arg) + .arg("build") + .arg("--release") + .arg("--locked") + .arg("--target") + .arg(WASM_TARGET) + .arg("--features") + .arg(feature) + .arg("--manifest-path") + .arg(&project.manifest_path) + .arg("--color=always"); + + if target == BuildTarget::Contract { + cmd.arg("-Z").arg("build-std=core,alloc"); + } + + let target_dir = match target { + BuildTarget::Contract => &project.contract_target_dir, + BuildTarget::DataDriver => &project.data_driver_target_dir, + }; + + cmd.env("CARGO_TARGET_DIR", target_dir) + .env("RUSTFLAGS", compose_rustflags(target)) + .current_dir(&project.project_dir) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .stdin(Stdio::inherit()); + apply_local_forge_overrides(&mut cmd, verbose); + + if verbose { + eprintln!("Running: {}", crate::ui::format_command(&cmd)); + } + + let status = cmd.status()?; + if !status.success() { + return Err(CliError::CommandFailed { + program: "cargo build".to_string(), + code: status.code().unwrap_or(1), + }); + } + + let wasm_path = target.wasm_path(project); + ensure_file_exists(&wasm_path)?; + + Ok(wasm_path) +} + +pub fn apply_local_forge_overrides(cmd: &mut Command, verbose: bool) { + let mut applied = Vec::new(); + + if let Some((forge_root, macro_root)) = local_forge_paths() { + append_patch_config(cmd, "dusk-forge", &forge_root); + append_patch_config(cmd, "dusk-forge-contract", ¯o_root); + applied.push(format!("dusk-forge -> {}", forge_root.display())); + applied.push(format!("dusk-forge-contract -> {}", macro_root.display())); + } + + if verbose && !applied.is_empty() { + eprintln!("Applying local overrides: {}", applied.join(", ")); + } +} + +fn local_forge_paths() -> Option<(PathBuf, PathBuf)> { + if !should_apply_local_forge_overrides() { + return None; + } + + let cli_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let forge_root = cli_dir.parent()?.to_path_buf(); + let macro_root = forge_root.join("contract-macro"); + + if forge_root.join("Cargo.toml").is_file() && macro_root.join("Cargo.toml").is_file() { + Some((forge_root, macro_root)) + } else { + None + } +} + +fn append_patch_config(cmd: &mut Command, crate_name: &str, path: &Path) { + let path_escaped = toml_escape(path.as_os_str()); + cmd.arg("--config").arg(format!( + "patch.crates-io.{crate_name}.path=\"{path_escaped}\"" + )); +} + +fn should_apply_local_forge_overrides() -> bool { + cfg!(debug_assertions) || env_flag("DUSK_FORGE_DEV") +} + +fn env_flag(name: &str) -> bool { + env::var_os(name).is_some_and(|value| !value.is_empty()) +} + +fn toml_escape(value: &OsStr) -> String { + let raw = value.to_string_lossy(); + raw.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn compose_rustflags(target: BuildTarget) -> String { + let mut parts: Vec = env::var("RUSTFLAGS") + .ok() + .map(|existing| { + existing + .split_whitespace() + .map(ToString::to_string) + .collect() + }) + .unwrap_or_default(); + + if let Some(home) = home_dir_from_env() { + parts.push("--remap-path-prefix".to_string()); + parts.push(format!("{home}=")); + } + + if target == BuildTarget::Contract { + parts.push("-C".to_string()); + parts.push(format!("link-args=-zstack-size={STACK_SIZE}")); + } + + parts.join(" ") +} + +fn home_dir_from_env() -> Option { + for key in ["HOME", "USERPROFILE"] { + if let Ok(value) = env::var(key) { + if !value.is_empty() { + return Some(value); + } + } + } + + None +} + +fn ensure_file_exists(path: &Path) -> Result<()> { + if path.exists() { + Ok(()) + } else { + Err(CliError::Message(format!( + "expected build artifact not found: {}", + path.display() + ))) + } +} diff --git a/cli/src/build_runner/wasm_opt.rs b/cli/src/build_runner/wasm_opt.rs new file mode 100644 index 0000000..200ba8c --- /dev/null +++ b/cli/src/build_runner/wasm_opt.rs @@ -0,0 +1,37 @@ +use std::{path::Path, process::Command}; + +use crate::error::{CliError, Result}; +use crate::tools; + +pub fn optimize_if_available(wasm_path: &Path, verbose: bool) -> Result { + let wasm_opt = match tools::find_in_path("wasm-opt") { + Some(path) => path, + None => return Ok(false), + }; + + let mut cmd = Command::new(&wasm_opt); + cmd.arg("-Oz") + .arg("--strip-debug") + .arg(wasm_path) + .arg("-o") + .arg(wasm_path); + + if verbose { + eprintln!( + "Running: {} -Oz --strip-debug {} -o {}", + wasm_opt.display(), + wasm_path.display(), + wasm_path.display() + ); + } + + let status = cmd.status()?; + if !status.success() { + return Err(CliError::CommandFailed { + program: wasm_opt.display().to_string(), + code: status.code().unwrap_or(1), + }); + } + + Ok(true) +} diff --git a/cli/src/cli.rs b/cli/src/cli.rs new file mode 100644 index 0000000..bff7668 --- /dev/null +++ b/cli/src/cli.rs @@ -0,0 +1,111 @@ +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand, ValueEnum}; + +use crate::build_runner::BuildTarget; + +#[derive(Debug, Clone, Copy, ValueEnum, Default)] +pub enum BuildScope { + /// Build the on-chain contract WASM. + Contract, + /// Build the off-chain data-driver WASM. + DataDriver, + /// Build both contract and data-driver WASMs. + #[default] + All, +} + +impl BuildScope { + pub fn expand(self) -> Vec { + match self { + Self::Contract => vec![BuildTarget::Contract], + Self::DataDriver => vec![BuildTarget::DataDriver], + Self::All => vec![BuildTarget::Contract, BuildTarget::DataDriver], + } + } + + pub fn needs_rust_src(self) -> bool { + matches!(self, Self::Contract | Self::All) + } +} + +#[derive(Debug, Parser)] +#[command(name = "dusk-forge")] +#[command(bin_name = "dusk-forge")] +#[command(about = "CLI for scaffolding and building Dusk Forge contracts")] +#[command(version)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Debug, Subcommand)] +pub enum Commands { + /// Scaffold a new contract project. + New(NewArgs), + /// Build WASM artifacts (contract, data-driver, or all). + Build(BuildArgs), + /// Build contract WASM and run cargo tests. + Test(TestArgs), + /// Validate project structure and toolchain. + Check(ProjectOptions), +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum TemplateChoice { + Counter, + Empty, +} + +#[derive(Debug, Args)] +pub struct NewArgs { + /// Name of the new contract project (kebab-case). + pub name: String, + + /// Directory in which the new project folder will be created. + #[arg(long, default_value = ".")] + pub path: PathBuf, + + /// Built-in template to use. + #[arg(long, value_enum, default_value_t = TemplateChoice::Counter)] + pub template: TemplateChoice, + + /// Skip `git init` in the created project. + #[arg(long)] + pub no_git: bool, + + /// Enable verbose output. + #[arg(short, long)] + pub verbose: bool, +} + +#[derive(Debug, Args)] +pub struct ProjectOptions { + /// Path to the contract project directory. + #[arg(long, default_value = ".")] + pub path: PathBuf, + + /// Enable verbose output. + #[arg(short, long)] + pub verbose: bool, +} + +#[derive(Debug, Args)] +pub struct BuildArgs { + #[command(flatten)] + pub project: ProjectOptions, + + /// Which WASM target to build. + #[arg(value_enum, default_value_t)] + pub target: BuildScope, +} + +#[derive(Debug, Args)] +#[command(trailing_var_arg = true)] +pub struct TestArgs { + #[command(flatten)] + pub project: ProjectOptions, + + /// Extra args passed through to `cargo test --release`. + pub cargo_test_args: Vec, +} diff --git a/cli/src/commands/build.rs b/cli/src/commands/build.rs new file mode 100644 index 0000000..dae06c6 --- /dev/null +++ b/cli/src/commands/build.rs @@ -0,0 +1,42 @@ +use std::fs; + +use crate::{ + build_runner, + cli::BuildArgs, + error::Result, + project::{detect, metadata}, + toolchain, ui, +}; + +pub fn run(args: BuildArgs) -> Result<()> { + let project = metadata::load(&args.project.path)?; + detect::ensure_forge_project(&project.project_dir)?; + + toolchain::ensure_build(&project.project_dir, args.target.needs_rust_src())?; + + for target in args.target.expand() { + ui::status(format!( + "Building {} WASM ({})", + target.label(), + project.crate_name + )); + + let wasm_path = build_runner::build(&project, target, args.project.verbose)?; + let optimized = + build_runner::wasm_opt::optimize_if_available(&wasm_path, args.project.verbose)?; + + let size = fs::metadata(&wasm_path)?.len(); + if !optimized { + ui::warn("wasm-opt not found, skipping optimization"); + } + + ui::success(format!( + "{} wasm: {} ({})", + target.label(), + wasm_path.display(), + ui::format_bytes(size) + )); + } + + Ok(()) +} diff --git a/cli/src/commands/check.rs b/cli/src/commands/check.rs new file mode 100644 index 0000000..3e09af9 --- /dev/null +++ b/cli/src/commands/check.rs @@ -0,0 +1,100 @@ +use crate::{ + cli::ProjectOptions, + error::{CliError, Result}, + project::{detect, metadata}, + toolchain, ui, +}; + +pub fn run(args: ProjectOptions) -> Result<()> { + let project = metadata::load(&args.path)?; + let checks = detect::inspect_manifest(&project.project_dir)?; + let toolchain = toolchain::inspect(&project.project_dir)?; + + ui::status(format!( + "Checking project at {}", + project.project_dir.display() + )); + + let mut failures = 0; + + record( + "dusk-forge dependency present", + checks.has_dusk_forge_dependency, + &mut failures, + ); + record( + "lib crate-type includes cdylib", + checks.has_cdylib, + &mut failures, + ); + record( + "feature 'contract' exists", + checks.has_contract_feature, + &mut failures, + ); + record( + "feature 'data-driver' or 'data-driver-js' exists", + checks.has_data_driver_feature, + &mut failures, + ); + record( + "profile.release.overflow-checks = true", + checks.has_release_overflow_checks, + &mut failures, + ); + + record( + "src/lib.rs exists", + project.project_dir.join("src/lib.rs").exists(), + &mut failures, + ); + record( + "tests/ directory exists", + project.project_dir.join("tests").exists(), + &mut failures, + ); + record( + "rust-toolchain.toml exists", + project.project_dir.join("rust-toolchain.toml").exists(), + &mut failures, + ); + record( + "Cargo.lock exists", + project.project_dir.join("Cargo.lock").exists(), + &mut failures, + ); + + let toolchain_check = format!("toolchain '{}' available", toolchain.channel); + record(&toolchain_check, toolchain.installed, &mut failures); + let target_check = format!( + "wasm32-unknown-unknown target installed for {}", + toolchain.channel + ); + record(&target_check, toolchain.wasm_target, &mut failures); + let rust_src_check = format!("rust-src component installed for {}", toolchain.channel); + record(&rust_src_check, toolchain.rust_src, &mut failures); + + if let Some(path) = toolchain.wasm_opt { + ui::success(format!("wasm-opt found at {}", path.display())); + } else { + ui::warn("wasm-opt not found (optional, but recommended for smaller binaries)"); + } + + if failures > 0 { + return Err(CliError::Message(format!( + "check failed with {failures} issue(s)" + ))); + } + + ui::success("All checks passed"); + Ok(()) +} + +fn record(name: &str, ok: bool, failures: &mut usize) { + if ok { + ui::success(name); + } else { + *failures += 1; + ui::error(name); + } +} diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs new file mode 100644 index 0000000..0c985c2 --- /dev/null +++ b/cli/src/commands/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod check; +pub mod new; +pub mod test; diff --git a/cli/src/commands/new.rs b/cli/src/commands/new.rs new file mode 100644 index 0000000..6b1c4a6 --- /dev/null +++ b/cli/src/commands/new.rs @@ -0,0 +1,137 @@ +use std::{fs, path::Path, process::Command}; + +use crate::{ + build_runner, + cli::{NewArgs, TemplateChoice}, + error::{CliError, Result}, + template::{ + embedded::TemplateKind, + engine::{render_template, validate_contract_name}, + }, + toolchain, ui, +}; + +pub fn run(args: NewArgs) -> Result<()> { + let parsed_name = validate_contract_name(&args.name)?; + let destination = args.path.join(&parsed_name.kebab); + + if destination.exists() { + return Err(CliError::PathAlreadyExists(destination)); + } + + ui::status(format!("Creating project at {}", destination.display())); + + fs::create_dir_all(destination.join("src"))?; + fs::create_dir_all(destination.join("tests"))?; + + let template_kind = match args.template { + TemplateChoice::Counter => TemplateKind::Counter, + TemplateChoice::Empty => TemplateKind::Empty, + }; + + let rendered = render_template(template_kind, &parsed_name); + + write_file( + &destination.join("Cargo.toml"), + &rendered.cargo_toml, + args.verbose, + )?; + write_file( + &destination.join("src/lib.rs"), + &rendered.lib_rs, + args.verbose, + )?; + write_file( + &destination.join("tests/contract.rs"), + &rendered.test_rs, + args.verbose, + )?; + write_file( + &destination.join("rust-toolchain.toml"), + &rendered.rust_toolchain_toml, + args.verbose, + )?; + write_file( + &destination.join(".gitignore"), + &rendered.gitignore, + args.verbose, + )?; + write_file( + &destination.join("Makefile"), + &rendered.makefile, + args.verbose, + )?; + + generate_lockfile(&destination, args.verbose)?; + + if !args.no_git { + maybe_init_git(&destination, args.verbose)?; + } + + ui::success(format!("Project '{}' created", parsed_name.kebab)); + println!("Next steps:"); + println!(" cd {}", destination.display()); + println!(" dusk-forge check"); + println!(" dusk-forge build"); + + Ok(()) +} + +fn write_file(path: &Path, content: &str, verbose: bool) -> Result<()> { + fs::write(path, content)?; + if verbose { + ui::status(format!("Wrote {}", path.display())); + } + Ok(()) +} + +fn maybe_init_git(destination: &Path, verbose: bool) -> Result<()> { + let output = Command::new("git") + .arg("init") + .current_dir(destination) + .output(); + + match output { + Ok(output) if output.status.success() => { + if verbose { + ui::status("Initialized git repository"); + } + Ok(()) + } + Ok(_) => { + ui::warn("`git init` failed, continuing without git repository"); + Ok(()) + } + Err(_) => { + ui::warn("`git` not found, skipping git initialization"); + Ok(()) + } + } +} + +fn generate_lockfile(destination: &Path, verbose: bool) -> Result<()> { + ui::status("Generating Cargo.lock"); + + let mut cmd = Command::new("cargo"); + cmd.arg(toolchain::cargo_toolchain_arg(destination)?) + .arg("generate-lockfile") + .current_dir(destination); + build_runner::apply_local_forge_overrides(&mut cmd, verbose); + + let status = cmd.status()?; + if !status.success() { + return Err(CliError::CommandFailed { + program: "cargo generate-lockfile".to_string(), + code: status.code().unwrap_or(1), + }); + } + + if verbose { + ui::status(format!( + "Wrote {}", + destination.join("Cargo.lock").display() + )); + } + + Ok(()) +} diff --git a/cli/src/commands/test.rs b/cli/src/commands/test.rs new file mode 100644 index 0000000..f9958e8 --- /dev/null +++ b/cli/src/commands/test.rs @@ -0,0 +1,54 @@ +use std::process::{Command, Stdio}; + +use crate::{ + build_runner::{self, BuildTarget}, + cli::TestArgs, + error::{CliError, Result}, + project::{detect, metadata}, + toolchain, ui, +}; + +pub fn run(args: TestArgs) -> Result<()> { + let project = metadata::load(&args.project.path)?; + detect::ensure_forge_project(&project.project_dir)?; + + toolchain::ensure_build(&project.project_dir, true)?; + + ui::status("Building contract WASM for tests"); + let wasm_path = build_runner::build(&project, BuildTarget::Contract, args.project.verbose)?; + let optimized = + build_runner::wasm_opt::optimize_if_available(&wasm_path, args.project.verbose)?; + if !optimized { + ui::warn("wasm-opt not found, skipping optimization"); + } + + ui::status("Running cargo test --release"); + let mut cmd = Command::new("cargo"); + cmd.arg(toolchain::cargo_toolchain_arg(&project.project_dir)?) + .arg("test") + .arg("--release") + .arg("--locked") + .arg("--manifest-path") + .arg(&project.manifest_path) + .args(&args.cargo_test_args) + .current_dir(&project.project_dir) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .stdin(Stdio::inherit()); + build_runner::apply_local_forge_overrides(&mut cmd, args.project.verbose); + + if args.project.verbose { + eprintln!("Running: {}", ui::format_command(&cmd)); + } + + let status = cmd.status()?; + if !status.success() { + return Err(CliError::CommandFailed { + program: "cargo test".to_string(), + code: status.code().unwrap_or(1), + }); + } + + ui::success("Tests completed"); + Ok(()) +} diff --git a/cli/src/error.rs b/cli/src/error.rs new file mode 100644 index 0000000..3485910 --- /dev/null +++ b/cli/src/error.rs @@ -0,0 +1,32 @@ +use std::path::PathBuf; + +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum CliError { + #[error("{0}")] + Message(String), + + #[error("invalid contract name '{name}': {reason}")] + InvalidContractName { name: String, reason: String }, + + #[error("path already exists: {0}")] + PathAlreadyExists(PathBuf), + + #[error("expected a Dusk Forge contract project at {0}")] + NotAForgeProject(PathBuf), + + #[error("command failed: {program} (exit code {code})")] + CommandFailed { program: String, code: i32 }, + + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("cargo metadata error: {0}")] + CargoMetadata(#[from] cargo_metadata::Error), + + #[error("TOML parse error: {0}")] + Toml(#[from] toml::de::Error), +} diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 0000000..6b303e4 --- /dev/null +++ b/cli/src/main.rs @@ -0,0 +1,31 @@ +mod build_runner; +mod cli; +mod commands; +mod error; +mod project; +mod template; +mod toolchain; +mod tools; +mod ui; + +use clap::Parser; +use cli::{Cli, Commands}; +use error::Result; + +fn main() { + if let Err(err) = run() { + ui::error(err.to_string()); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + let cli = Cli::parse(); + + match cli.command { + Commands::New(args) => commands::new::run(args), + Commands::Build(args) => commands::build::run(args), + Commands::Test(args) => commands::test::run(args), + Commands::Check(args) => commands::check::run(args), + } +} diff --git a/cli/src/project/detect.rs b/cli/src/project/detect.rs new file mode 100644 index 0000000..c86c84a --- /dev/null +++ b/cli/src/project/detect.rs @@ -0,0 +1,147 @@ +use std::{fs, path::Path}; + +use toml::Value; + +use crate::error::{CliError, Result}; + +#[derive(Debug, Clone)] +pub struct ManifestChecks { + pub has_dusk_forge_dependency: bool, + pub has_cdylib: bool, + pub has_contract_feature: bool, + pub has_data_driver_feature: bool, + pub has_release_overflow_checks: bool, +} + +pub fn ensure_forge_project(project_dir: &Path) -> Result { + let checks = inspect_manifest(project_dir)?; + + if !checks.has_dusk_forge_dependency || !checks.has_cdylib { + return Err(CliError::NotAForgeProject(project_dir.to_path_buf())); + } + + Ok(checks) +} + +pub fn inspect_manifest(project_dir: &Path) -> Result { + let manifest = load_manifest(project_dir)?; + + Ok(ManifestChecks { + has_dusk_forge_dependency: has_dusk_forge_dependency(&manifest), + has_cdylib: has_cdylib(&manifest), + has_contract_feature: has_feature(&manifest, "contract"), + has_data_driver_feature: has_feature(&manifest, "data-driver") + || has_feature(&manifest, "data-driver-js"), + has_release_overflow_checks: has_release_overflow_checks(&manifest), + }) +} + +pub fn load_manifest(project_dir: &Path) -> Result { + let manifest_path = project_dir.join("Cargo.toml"); + let content = fs::read_to_string(&manifest_path)?; + Ok(content.parse::()?) +} + +pub fn resolve_data_driver_feature(project_dir: &Path) -> Result<&'static str> { + let manifest = load_manifest(project_dir)?; + preferred_data_driver_feature(&manifest).ok_or_else(|| { + CliError::Message( + "project is missing a `data-driver` or `data-driver-js` feature".to_string(), + ) + }) +} + +pub fn preferred_data_driver_feature(manifest: &Value) -> Option<&'static str> { + if has_feature(manifest, "data-driver-js") { + Some("data-driver-js") + } else if has_feature(manifest, "data-driver") { + Some("data-driver") + } else { + None + } +} + +fn has_dusk_forge_dependency(manifest: &Value) -> bool { + has_dependency(manifest.get("dependencies"), "dusk-forge") + || manifest + .get("target") + .and_then(Value::as_table) + .and_then(|target| target.get("cfg(target_family = \"wasm\")")) + .and_then(|cfg| cfg.get("dependencies")) + .is_some_and(|deps| has_dependency(Some(deps), "dusk-forge")) +} + +fn has_dependency(table: Option<&Value>, name: &str) -> bool { + table + .and_then(Value::as_table) + .is_some_and(|deps| deps.contains_key(name)) +} + +fn has_cdylib(manifest: &Value) -> bool { + manifest + .get("lib") + .and_then(|lib| lib.get("crate-type")) + .and_then(Value::as_array) + .is_some_and(|types| types.iter().any(|ty| ty.as_str() == Some("cdylib"))) +} + +fn has_feature(manifest: &Value, name: &str) -> bool { + manifest + .get("features") + .and_then(Value::as_table) + .is_some_and(|features| features.contains_key(name)) +} + +fn has_release_overflow_checks(manifest: &Value) -> bool { + manifest + .get("profile") + .and_then(|p| p.get("release")) + .and_then(|release| release.get("overflow-checks")) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::preferred_data_driver_feature; + + fn parse_manifest(source: &str) -> toml::Value { + source.parse().expect("valid manifest") + } + + #[test] + fn resolves_preferred_data_driver_feature() { + let cases = [ + ( + "prefers data-driver-js when both features exist", + r#" + [features] + data-driver = [] + data-driver-js = ["data-driver"] + "#, + Some("data-driver-js"), + ), + ( + "falls back to data-driver when js feature is missing", + r#" + [features] + data-driver = [] + "#, + Some("data-driver"), + ), + ( + "returns none when no data-driver feature exists", + r#" + [features] + contract = [] + "#, + None, + ), + ]; + + for (name, source, expected) in cases { + let manifest = parse_manifest(source); + assert_eq!(preferred_data_driver_feature(&manifest), expected, "{name}"); + } + } +} diff --git a/cli/src/project/metadata.rs b/cli/src/project/metadata.rs new file mode 100644 index 0000000..1669b2b --- /dev/null +++ b/cli/src/project/metadata.rs @@ -0,0 +1,86 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use cargo_metadata::{MetadataCommand, Package}; + +use crate::error::{CliError, Result}; +use crate::toolchain::WASM_TARGET; + +#[derive(Debug, Clone)] +pub struct ProjectMetadata { + pub project_dir: PathBuf, + pub manifest_path: PathBuf, + pub crate_name: String, + pub contract_target_dir: PathBuf, + pub data_driver_target_dir: PathBuf, + pub contract_wasm_path: PathBuf, + pub data_driver_wasm_path: PathBuf, +} + +pub fn load(project_dir: &Path) -> Result { + let project_dir = fs::canonicalize(project_dir)?; + let manifest_path = project_dir.join("Cargo.toml"); + if !manifest_path.exists() { + return Err(CliError::Message(format!( + "missing Cargo.toml at {}", + manifest_path.display() + ))); + } + + let manifest_utf8 = cargo_metadata::camino::Utf8PathBuf::from_path_buf(manifest_path.clone()) + .map_err(|_| { + CliError::Message(format!( + "manifest path contains invalid UTF-8: {}", + manifest_path.display() + )) + })?; + + let metadata = MetadataCommand::new() + .manifest_path(&manifest_path) + .no_deps() + .exec()?; + + let package = select_package(&metadata.packages, &manifest_utf8).ok_or_else(|| { + CliError::Message(format!( + "unable to resolve package metadata for {}", + manifest_path.display() + )) + })?; + + let crate_name = package.name.clone(); + let crate_name_snake = crate_name.replace('-', "_"); + let workspace_root = PathBuf::from(metadata.workspace_root.as_std_path()); + let contract_target_dir = workspace_root.join("target/contract"); + let data_driver_target_dir = workspace_root.join("target/data-driver"); + + let contract_wasm_path = contract_target_dir + .join(WASM_TARGET) + .join("release") + .join(format!("{crate_name_snake}.wasm")); + let data_driver_wasm_path = data_driver_target_dir + .join(WASM_TARGET) + .join("release") + .join(format!("{crate_name_snake}.wasm")); + + Ok(ProjectMetadata { + project_dir, + manifest_path, + crate_name, + contract_target_dir, + data_driver_target_dir, + contract_wasm_path, + data_driver_wasm_path, + }) +} + +fn select_package<'a>( + packages: &'a [Package], + manifest_path: &cargo_metadata::camino::Utf8PathBuf, +) -> Option<&'a Package> { + packages + .iter() + .find(|pkg| pkg.manifest_path == *manifest_path) + .or_else(|| packages.first()) +} diff --git a/cli/src/project/mod.rs b/cli/src/project/mod.rs new file mode 100644 index 0000000..a4092a0 --- /dev/null +++ b/cli/src/project/mod.rs @@ -0,0 +1,2 @@ +pub mod detect; +pub mod metadata; diff --git a/cli/src/template/embedded.rs b/cli/src/template/embedded.rs new file mode 100644 index 0000000..e93dd37 --- /dev/null +++ b/cli/src/template/embedded.rs @@ -0,0 +1,100 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TemplateKind { + Counter, + Empty, +} + +#[derive(Debug, Clone, Copy)] +pub struct TemplateFiles { + pub cargo_toml: &'static str, + pub lib_rs: &'static str, + pub test_rs: &'static str, + pub rust_toolchain_toml: &'static str, + pub gitignore: &'static str, + pub makefile: &'static str, +} + +const COUNTER_CARGO_TOML: &str = include_str!("../../../contract-template/Cargo.toml"); +const COUNTER_LIB_RS: &str = include_str!("../../../contract-template/src/lib.rs"); +const COUNTER_TEST_RS: &str = include_str!("../../../contract-template/tests/contract.rs"); +const COUNTER_RUST_TOOLCHAIN_TOML: &str = include_str!("../../../rust-toolchain.toml"); +const COUNTER_GITIGNORE: &str = include_str!("../../../contract-template/.gitignore"); +const COUNTER_MAKEFILE: &str = include_str!("../../../contract-template/Makefile"); + +const EMPTY_LIB_RS: &str = r#"//! Minimal contract template for `#[contract]`. + +#![no_std] +#![cfg(target_family = "wasm")] + +#[cfg(not(any(feature = "contract", feature = "data-driver")))] +compile_error!("Enable either 'contract' or 'data-driver' feature for WASM builds"); + +extern crate alloc; +use dusk_core as _; + +#[dusk_forge::contract] +mod YOUR_MODULE_NAME { + /// Contract state. + pub struct YOUR_STRUCT_NAME; + + impl YOUR_STRUCT_NAME { + /// Initialize an empty contract state. + pub const fn new() -> Self { + Self + } + } +} +"#; + +const EMPTY_TEST_RS: &str = r#"//! Contract deployment and integration tests. + +use dusk_core::abi::ContractId; +use dusk_vm::{ContractData, VM}; + +const CONTRACT_BYTECODE: &[u8] = + include_bytes!("../target/contract/wasm32-unknown-unknown/release/YOUR_CONTRACT_NAME.wasm"); + +const CONTRACT_ID: ContractId = ContractId::from_bytes([1; 32]); +const CHAIN_ID: u8 = 1; +const GAS_LIMIT: u64 = u64::MAX; +const OWNER: [u8; 32] = [0; 32]; + +#[test] +fn test_contract_deploys() { + let vm = VM::ephemeral().expect("creating ephemeral VM should succeed"); + let mut session = vm.genesis_session(CHAIN_ID); + + let deployed_id = session + .deploy( + CONTRACT_BYTECODE, + ContractData::builder() + .owner(OWNER) + .contract_id(CONTRACT_ID), + GAS_LIMIT, + ) + .expect("deploying contract should succeed"); + + assert_eq!(deployed_id, CONTRACT_ID); +} +"#; + +pub fn files(template: TemplateKind) -> TemplateFiles { + match template { + TemplateKind::Counter => TemplateFiles { + cargo_toml: COUNTER_CARGO_TOML, + lib_rs: COUNTER_LIB_RS, + test_rs: COUNTER_TEST_RS, + rust_toolchain_toml: COUNTER_RUST_TOOLCHAIN_TOML, + gitignore: COUNTER_GITIGNORE, + makefile: COUNTER_MAKEFILE, + }, + TemplateKind::Empty => TemplateFiles { + cargo_toml: COUNTER_CARGO_TOML, + lib_rs: EMPTY_LIB_RS, + test_rs: EMPTY_TEST_RS, + rust_toolchain_toml: COUNTER_RUST_TOOLCHAIN_TOML, + gitignore: COUNTER_GITIGNORE, + makefile: COUNTER_MAKEFILE, + }, + } +} diff --git a/cli/src/template/engine.rs b/cli/src/template/engine.rs new file mode 100644 index 0000000..4a415b3 --- /dev/null +++ b/cli/src/template/engine.rs @@ -0,0 +1,208 @@ +use crate::error::{CliError, Result}; + +use super::embedded::{files, TemplateKind}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContractName { + pub kebab: String, + pub module: String, + pub pascal: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderedTemplate { + pub cargo_toml: String, + pub lib_rs: String, + pub test_rs: String, + pub rust_toolchain_toml: String, + pub gitignore: String, + pub makefile: String, +} + +pub fn validate_contract_name(name: &str) -> Result { + if name.is_empty() { + return Err(CliError::InvalidContractName { + name: name.to_string(), + reason: "name cannot be empty".to_string(), + }); + } + + if !name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err(CliError::InvalidContractName { + name: name.to_string(), + reason: "use lowercase letters, digits, and hyphens only".to_string(), + }); + } + + if !name.chars().next().is_some_and(|c| c.is_ascii_lowercase()) { + return Err(CliError::InvalidContractName { + name: name.to_string(), + reason: "name must start with a lowercase letter".to_string(), + }); + } + + if name.ends_with('-') { + return Err(CliError::InvalidContractName { + name: name.to_string(), + reason: "name cannot end with a hyphen".to_string(), + }); + } + + if name.contains("--") { + return Err(CliError::InvalidContractName { + name: name.to_string(), + reason: "name cannot contain consecutive hyphens".to_string(), + }); + } + + let module = name.replace('-', "_"); + let pascal = name + .split('-') + .filter(|segment| !segment.is_empty()) + .map(to_pascal_segment) + .collect::>() + .join(""); + + Ok(ContractName { + kebab: name.to_string(), + module, + pascal, + }) +} + +pub fn render_template(template: TemplateKind, name: &ContractName) -> RenderedTemplate { + let template = files(template); + + RenderedTemplate { + cargo_toml: apply_common_replacements(template.cargo_toml, name), + lib_rs: apply_common_replacements(template.lib_rs, name), + test_rs: apply_test_replacements(template.test_rs, name), + rust_toolchain_toml: template.rust_toolchain_toml.to_string(), + gitignore: template.gitignore.to_string(), + makefile: template.makefile.to_string(), + } +} + +fn apply_test_replacements(content: &str, name: &ContractName) -> String { + let with_wasm_name = + content.replace("YOUR_CONTRACT_NAME.wasm", &format!("{}.wasm", name.module)); + apply_common_replacements(&with_wasm_name, name) +} + +fn apply_common_replacements(content: &str, name: &ContractName) -> String { + let intermediate = content + .replace("YOUR_CONTRACT_NAME", &name.kebab) + .replace("YOUR_MODULE_NAME", &name.module) + .replace("YOUR_STRUCT_NAME", &name.pascal) + .replace("mod counter", &format!("mod {}", name.module)); + + replace_identifier(&intermediate, "Counter", &name.pascal) +} + +fn replace_identifier(content: &str, from: &str, to: &str) -> String { + let mut result = String::with_capacity(content.len()); + let bytes = content.as_bytes(); + let from_bytes = from.as_bytes(); + + if from_bytes.is_empty() || bytes.len() < from_bytes.len() { + return content.to_string(); + } + + let mut i = 0; + while i + from_bytes.len() <= bytes.len() { + if let Some(relative_pos) = content[i..].find(from) { + let absolute_pos = i + relative_pos; + let before_is_ident = absolute_pos > 0 && is_identifier_byte(bytes[absolute_pos - 1]); + let after_index = absolute_pos + from_bytes.len(); + let after_is_ident = + after_index < bytes.len() && is_identifier_byte(bytes[after_index]); + + if before_is_ident || after_is_ident { + result.push_str(&content[i..=absolute_pos]); + i = absolute_pos + 1; + continue; + } + + result.push_str(&content[i..absolute_pos]); + result.push_str(to); + i = after_index; + } else { + break; + } + } + + result.push_str(&content[i..]); + result +} + +fn is_identifier_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + +fn to_pascal_segment(segment: &str) -> String { + let mut chars = segment.chars(); + match chars.next() { + Some(first) => { + let mut out = String::new(); + out.push(first.to_ascii_uppercase()); + out.extend(chars); + out + } + None => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_kebab_case_name() { + let parsed = validate_contract_name("my-counter").expect("name should be valid"); + assert_eq!(parsed.kebab, "my-counter"); + assert_eq!(parsed.module, "my_counter"); + assert_eq!(parsed.pascal, "MyCounter"); + } + + #[test] + fn rejects_invalid_characters() { + let err = validate_contract_name("MyCounter").expect_err("name should be invalid"); + assert!(err.to_string().contains("lowercase letters")); + } + + #[test] + fn renders_counter_template_replacements() { + let name = validate_contract_name("bridge-test").expect("valid"); + let rendered = render_template(TemplateKind::Counter, &name); + + assert!(rendered.cargo_toml.contains("name = \"bridge-test\"")); + assert!(rendered.lib_rs.contains("mod bridge_test")); + assert!(rendered.lib_rs.contains("pub struct BridgeTest")); + assert!(rendered.test_rs.contains("release/bridge_test.wasm")); + assert!(!rendered.test_rs.contains("YOUR_CONTRACT_NAME")); + } + + #[test] + fn renders_empty_template_without_counter_struct() { + let name = validate_contract_name("empty-app").expect("valid"); + let rendered = render_template(TemplateKind::Empty, &name); + + assert!(rendered.lib_rs.contains("mod empty_app")); + assert!(rendered.lib_rs.contains("pub struct EmptyApp")); + assert!(!rendered.lib_rs.contains("CountChanged")); + } + + #[test] + fn replace_identifier_only_replaces_standalone_identifiers() { + let rendered = replace_identifier( + "Counter Encounter Counter_ Counter::TOPIC", + "Counter", + "BridgeTest", + ); + + assert_eq!(rendered, "BridgeTest Encounter Counter_ BridgeTest::TOPIC"); + } +} diff --git a/cli/src/template/mod.rs b/cli/src/template/mod.rs new file mode 100644 index 0000000..f81a3de --- /dev/null +++ b/cli/src/template/mod.rs @@ -0,0 +1,2 @@ +pub mod embedded; +pub mod engine; diff --git a/cli/src/toolchain.rs b/cli/src/toolchain.rs new file mode 100644 index 0000000..ba66814 --- /dev/null +++ b/cli/src/toolchain.rs @@ -0,0 +1,155 @@ +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, +}; + +use crate::error::{CliError, Result}; +use crate::tools; + +pub const WASM_TARGET: &str = "wasm32-unknown-unknown"; + +#[derive(Debug, Clone)] +pub struct ToolchainStatus { + pub channel: String, + pub installed: bool, + pub wasm_target: bool, + pub rust_src: bool, + pub wasm_opt: Option, +} + +pub fn configured_channel(project_dir: &Path) -> Result { + let toolchain_file = resolve_toolchain_file(project_dir).ok_or_else(|| { + CliError::Message(format!( + "missing rust-toolchain.toml (or rust-toolchain) in {} or its parents", + project_dir.display() + )) + })?; + + read_toolchain_channel(&toolchain_file).ok_or_else(|| { + CliError::Message(format!( + "unable to read toolchain channel from {}", + toolchain_file.display() + )) + }) +} + +pub fn cargo_toolchain_arg(project_dir: &Path) -> Result { + Ok(format!("+{}", configured_channel(project_dir)?)) +} + +pub fn inspect(project_dir: &Path) -> Result { + let channel = configured_channel(project_dir)?; + + let installed = command_success("rustc", &[&format!("+{channel}"), "--version"]); + + let wasm_target = command_contains( + "rustup", + &["target", "list", "--installed", "--toolchain", &channel], + WASM_TARGET, + ); + + let rust_src = command_contains( + "rustup", + &["component", "list", "--installed", "--toolchain", &channel], + "rust-src", + ); + + let wasm_opt = tools::find_in_path("wasm-opt"); + + Ok(ToolchainStatus { + channel, + installed, + wasm_target, + rust_src, + wasm_opt, + }) +} + +pub fn ensure_build(project_dir: &Path, needs_rust_src: bool) -> Result { + let status = inspect(project_dir)?; + + if !status.installed { + return Err(CliError::Message(format!( + "missing Rust toolchain '{}'. Install with: rustup toolchain install {}", + status.channel, status.channel + ))); + } + + if !status.wasm_target { + return Err(CliError::Message(format!( + "missing {WASM_TARGET} target for toolchain '{}'. Install with: rustup target add {WASM_TARGET} --toolchain {}", + status.channel, status.channel + ))); + } + + if needs_rust_src && !status.rust_src { + return Err(CliError::Message(format!( + "missing rust-src component for toolchain '{}'. Install with: rustup component add rust-src --toolchain {}", + status.channel, status.channel + ))); + } + + Ok(status) +} + +fn resolve_toolchain_file(project_dir: &Path) -> Option { + for dir in project_dir.ancestors() { + let toolchain_toml = dir.join("rust-toolchain.toml"); + if toolchain_toml.is_file() { + return Some(toolchain_toml); + } + + let toolchain_plain = dir.join("rust-toolchain"); + if toolchain_plain.is_file() { + return Some(toolchain_plain); + } + } + None +} + +fn read_toolchain_channel(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + if path.file_name()?.to_str()? == "rust-toolchain.toml" { + parse_toolchain_toml_channel(&content) + } else { + parse_toolchain_plain_channel(&content) + } +} + +fn parse_toolchain_plain_channel(content: &str) -> Option { + let value = content.lines().next()?.trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} + +fn parse_toolchain_toml_channel(content: &str) -> Option { + let value: toml::Value = toml::from_str(content).ok()?; + value + .get("toolchain")? + .get("channel")? + .as_str() + .map(ToString::to_string) +} + +fn command_success(program: &str, args: &[&str]) -> bool { + Command::new(program) + .args(args) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +fn command_contains(program: &str, args: &[&str], needle: &str) -> bool { + let output = Command::new(program).args(args).output(); + match output { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + stdout.lines().any(|line| line.contains(needle)) + } + _ => false, + } +} diff --git a/cli/src/tools.rs b/cli/src/tools.rs new file mode 100644 index 0000000..631ae0c --- /dev/null +++ b/cli/src/tools.rs @@ -0,0 +1,66 @@ +use std::{ + env, + path::{Path, PathBuf}, +}; + +pub fn find_in_path(program: &str) -> Option { + let program_path = Path::new(program); + if program_path.components().count() > 1 { + return is_executable(program_path).then(|| program_path.to_path_buf()); + } + + let path = env::var_os("PATH")?; + for dir in env::split_paths(&path) { + for candidate in program_candidates(program) { + let full_path = dir.join(candidate); + if is_executable(&full_path) { + return Some(full_path); + } + } + } + + None +} + +fn program_candidates(program: &str) -> Vec { + #[cfg(windows)] + { + let mut candidates = vec![program.to_string()]; + if Path::new(program).extension().is_none() { + if let Some(pathext) = env::var_os("PATHEXT") { + for ext in pathext.to_string_lossy().split(';') { + if !ext.is_empty() { + candidates.push(format!("{program}{ext}")); + } + } + } + } + candidates + } + + #[cfg(not(windows))] + { + vec![program.to_string()] + } +} + +fn is_executable(path: &Path) -> bool { + if !path.is_file() { + return false; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = path + .metadata() + .map(|meta| meta.permissions().mode()) + .unwrap_or(0); + mode & 0o111 != 0 + } + + #[cfg(not(unix))] + { + true + } +} diff --git a/cli/src/ui.rs b/cli/src/ui.rs new file mode 100644 index 0000000..241e98a --- /dev/null +++ b/cli/src/ui.rs @@ -0,0 +1,40 @@ +use colored::Colorize; + +pub fn status(message: impl AsRef) { + eprintln!("{} {}", "==>".blue().bold(), message.as_ref()); +} + +pub fn success(message: impl AsRef) { + eprintln!("{} {}", "ok".green().bold(), message.as_ref()); +} + +pub fn warn(message: impl AsRef) { + eprintln!("{} {}", "warn".yellow().bold(), message.as_ref()); +} + +pub fn error(message: impl AsRef) { + eprintln!("{} {}", "error".red().bold(), message.as_ref()); +} + +pub fn format_command(cmd: &std::process::Command) -> String { + let program = cmd.get_program().to_string_lossy(); + let args = cmd + .get_args() + .map(|arg| arg.to_string_lossy()) + .collect::>() + .join(" "); + format!("{program} {args}") +} + +pub fn format_bytes(bytes: u64) -> String { + const KIB: u64 = 1024; + const MIB: u64 = KIB * 1024; + + if bytes >= MIB { + format!("{:.2} MiB", bytes as f64 / MIB as f64) + } else if bytes >= KIB { + format!("{:.2} KiB", bytes as f64 / KIB as f64) + } else { + format!("{bytes} B") + } +} diff --git a/cli/tests/new_command.rs b/cli/tests/new_command.rs new file mode 100644 index 0000000..9f456fc --- /dev/null +++ b/cli/tests/new_command.rs @@ -0,0 +1,71 @@ +use std::fs; + +use assert_cmd::cargo::cargo_bin_cmd; +use tempfile::tempdir; + +#[test] +fn new_scaffolds_counter_template() { + let tmp = tempdir().expect("tempdir"); + + cargo_bin_cmd!("dusk-forge") + .args([ + "new", + "my-test", + "--no-git", + "--path", + tmp.path().to_str().expect("utf-8 path"), + ]) + .assert() + .success(); + + let project = tmp.path().join("my-test"); + assert!(project.join("Cargo.toml").exists()); + assert!(project.join("src/lib.rs").exists()); + assert!(project.join("tests/contract.rs").exists()); + assert!(project.join("rust-toolchain.toml").exists()); + assert!(project.join("Cargo.lock").exists()); + + let cargo = fs::read_to_string(project.join("Cargo.toml")).expect("read Cargo.toml"); + let lib = fs::read_to_string(project.join("src/lib.rs")).expect("read lib.rs"); + let test = fs::read_to_string(project.join("tests/contract.rs")).expect("read test file"); + let rust_toolchain = + fs::read_to_string(project.join("rust-toolchain.toml")).expect("read rust-toolchain.toml"); + + assert!(cargo.contains("name = \"my-test\"")); + assert!(!cargo.contains("YOUR_CONTRACT_NAME")); + assert!(lib.contains("mod my_test")); + assert!(lib.contains("pub struct MyTest")); + assert!(test.contains("release/my_test.wasm")); + assert!(!test.contains("YOUR_CONTRACT_NAME")); + assert!(!test.contains("TODO")); + assert!(rust_toolchain.contains("channel = \"nightly-2024-07-30\"")); +} + +#[test] +fn new_scaffolds_empty_template() { + let tmp = tempdir().expect("tempdir"); + + cargo_bin_cmd!("dusk-forge") + .args([ + "new", + "blank-contract", + "--template", + "empty", + "--no-git", + "--path", + tmp.path().to_str().expect("utf-8 path"), + ]) + .assert() + .success(); + + let project = tmp.path().join("blank-contract"); + let lib = fs::read_to_string(project.join("src/lib.rs")).expect("read lib.rs"); + let test = fs::read_to_string(project.join("tests/contract.rs")).expect("read test file"); + assert!(project.join("rust-toolchain.toml").exists()); + assert!(project.join("Cargo.lock").exists()); + + assert!(lib.contains("mod blank_contract")); + assert!(lib.contains("pub struct BlankContract")); + assert!(!lib.contains("CountChanged")); + assert!(!test.contains("TODO")); +} diff --git a/contract-template/Cargo.toml b/contract-template/Cargo.toml index b715abb..2f2d26c 100644 --- a/contract-template/Cargo.toml +++ b/contract-template/Cargo.toml @@ -46,14 +46,25 @@ edition = "2021" [target.'cfg(target_family = "wasm")'.dependencies] dusk-core = "1.4" dusk-data-driver = { version = "0.3", optional = true } -dusk-forge = "0.1" +dusk-forge = "0.2.2" # ----------------------------------------------------------------------------- # Dev Dependencies (for tests running on the host, not in WASM) # ----------------------------------------------------------------------------- [dev-dependencies] dusk-core = "1.4" -dusk-vm = "0.1" +dusk-vm = "1.4" +tempfile = "=3.10.1" + +# Pin transitive crates to versions compatible with the current nightly toolchain. +# Keep these optional so they constrain resolution for generated projects +# without becoming direct runtime dependencies of the template. +[dependencies] +blake2b_simd = { version = "=1.0.2", default-features = false, optional = true } +blake3 = { version = "=1.5.4", default-features = false, optional = true } +constant_time_eq = { version = "=0.3.1", optional = true } +msgpacker = { version = "=0.4.3", default-features = false, optional = true } +time-core = { version = "=0.1.6", optional = true } # ----------------------------------------------------------------------------- # Features diff --git a/contract-template/src/lib.rs b/contract-template/src/lib.rs index b9dbb88..37d752a 100644 --- a/contract-template/src/lib.rs +++ b/contract-template/src/lib.rs @@ -25,18 +25,9 @@ mod counter { value: u64, } - /// Event emitted when the counter changes. - #[derive(Debug)] - pub struct CountChanged { - /// Previous value. - pub old_value: u64, - /// New value. - pub new_value: u64, - } - impl Counter { /// Initialize a new counter with zero. - pub fn new() -> Self { + pub const fn new() -> Self { Self { value: 0 } } @@ -49,30 +40,21 @@ mod counter { pub fn increment(&mut self) { let old_value = self.value; self.value = self.value.saturating_add(1); - abi::emit("count_changed", CountChanged { - old_value, - new_value: self.value, - }); + abi::emit("count_changed", (old_value, self.value)); } /// Decrement the counter by one. pub fn decrement(&mut self) { let old_value = self.value; self.value = self.value.saturating_sub(1); - abi::emit("count_changed", CountChanged { - old_value, - new_value: self.value, - }); + abi::emit("count_changed", (old_value, self.value)); } /// Set the counter to a specific value. pub fn set_count(&mut self, value: u64) { let old_value = self.value; self.value = value; - abi::emit("count_changed", CountChanged { - old_value, - new_value: self.value, - }); + abi::emit("count_changed", (old_value, self.value)); } } } diff --git a/contract-template/tests/contract.rs b/contract-template/tests/contract.rs index 43c284c..9e838b8 100644 --- a/contract-template/tests/contract.rs +++ b/contract-template/tests/contract.rs @@ -1,37 +1,80 @@ //! Contract deployment and integration tests. -//! -//! These tests verify the contract deploys and functions correctly. use dusk_core::abi::ContractId; -use dusk_vm::{Session, VM}; +use dusk_vm::{ContractData, Session, VM}; -// Path to the compiled contract WASM const CONTRACT_BYTECODE: &[u8] = - include_bytes!("../../target/contract/wasm32-unknown-unknown/release/YOUR_CONTRACT_NAME.wasm"); - -// Contract ID for deployment + include_bytes!("../target/contract/wasm32-unknown-unknown/release/YOUR_CONTRACT_NAME.wasm"); const CONTRACT_ID: ContractId = ContractId::from_bytes([1; 32]); +const CHAIN_ID: u8 = 1; +const GAS_LIMIT: u64 = u64::MAX; +const OWNER: [u8; 32] = [0; 32]; + +struct TestHarness { + _vm: VM, + session: Session, +} + +fn deploy_counter() -> TestHarness { + let vm = VM::ephemeral().expect("creating ephemeral VM should succeed"); + let mut session = vm.genesis_session(CHAIN_ID); + + let deployed_id = session + .deploy( + CONTRACT_BYTECODE, + ContractData::builder() + .owner(OWNER) + .contract_id(CONTRACT_ID), + GAS_LIMIT, + ) + .expect("deploying contract should succeed"); + + assert_eq!(deployed_id, CONTRACT_ID); + + TestHarness { _vm: vm, session } +} -// TODO: Add your test setup here -// See tests-setup crate for VM session helpers +fn get_count(session: &mut Session) -> u64 { + session + .call::<_, u64>(CONTRACT_ID, "get_count", &(), GAS_LIMIT) + .expect("get_count call should succeed") + .data +} + +#[test] +fn test_contract_deploys_with_zero_state() { + let mut harness = deploy_counter(); + assert_eq!(get_count(&mut harness.session), 0); +} #[test] -fn test_contract_deploys() { - // TODO: Initialize VM session - // TODO: Deploy contract with CONTRACT_BYTECODE - // TODO: Call methods and verify results - - // Example structure: - // let vm = VM::new(...); - // let mut session = vm.session(...); - // session.deploy(CONTRACT_BYTECODE, CONTRACT_ID, ...); - // let result: u64 = session.call(CONTRACT_ID, "get_count", &()).unwrap(); - // assert_eq!(result, 0); +fn test_counter_mutations() { + let mut harness = deploy_counter(); + + harness + .session + .call::<_, ()>(CONTRACT_ID, "increment", &(), GAS_LIMIT) + .expect("increment call should succeed"); + harness + .session + .call::<_, ()>(CONTRACT_ID, "set_count", &42_u64, GAS_LIMIT) + .expect("set_count call should succeed"); + harness + .session + .call::<_, ()>(CONTRACT_ID, "decrement", &(), GAS_LIMIT) + .expect("decrement call should succeed"); + + assert_eq!(get_count(&mut harness.session), 41); } #[test] -fn test_increment() { - // TODO: Deploy contract - // TODO: Call increment - // TODO: Verify count increased +fn test_decrement_saturates_at_zero() { + let mut harness = deploy_counter(); + + harness + .session + .call::<_, ()>(CONTRACT_ID, "decrement", &(), GAS_LIMIT) + .expect("decrement call should succeed"); + + assert_eq!(get_count(&mut harness.session), 0); } diff --git a/tests/test-bridge/Cargo.toml b/tests/test-bridge/Cargo.toml index e6ff823..fd4f2ef 100644 --- a/tests/test-bridge/Cargo.toml +++ b/tests/test-bridge/Cargo.toml @@ -27,6 +27,7 @@ blake2b_simd = { workspace = true } blake3 = { workspace = true } constant_time_eq = { workspace = true } time-core = { workspace = true } +uuid = { workspace = true } # Transitive pin to keep generated test graphs on the same toolchain-compatible set. [features] # Contract WASM build