sdkt-cli 2.7.0

Command Line Interface (CLI) application combining core Soroban DevKit components.
//! Integration tests for `sdkt plugin init`.
//!
//! The first three tests are fast and offline (pure scaffold verification).
//! The last one compiles the scaffolded crate from scratch and is therefore
//! heavier; it pins the acceptance criteria that a scaffolded plugin builds
//! with `--features plugins` and that the placeholder rule is wired (produces a
//! finding) rather than merely compiling. The scaffold's `sdkt-audit`
//! dependency is patched to the in-tree workspace crate (not the published
//! crates.io version) so the templates are compiled against the current
//! workspace API. An additional `#[ignore]`d smoke test verifies the unpatched
//! standalone build resolves `sdkt-audit` from crates.io; the release workflow
//! runs it after the crates are published.

use assert_cmd::Command;
use predicates::prelude::*;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command as StdCommand;
use tempfile::TempDir;

fn sdkt() -> Command {
    Command::cargo_bin("sdkt").unwrap()
}

/// The documented file set generated by `sdkt plugin init`. Must stay in sync
/// with `PLUGIN_SCAFFOLD_FILES` in crates/sdkt-core/src/scaffold.rs.
const DOCUMENTED_FILES: &[&str] = &[
    "Cargo.toml",
    "src/lib.rs",
    "src/plugin_abi.rs",
    "src/plugin_abi_wasm.rs",
    "plugin/plugin.toml",
    "plugin-wasm/plugin.toml",
    "README.md",
    "tests/rule_test.rs",
    ".gitignore",
];

fn init_project(dir: &Path, name: &str) -> PathBuf {
    let project = dir.join(name);
    sdkt()
        .args(["plugin", "init", project.to_str().unwrap()])
        .assert()
        .success()
        .stdout(predicate::str::contains("Created plugin rule project '"));
    project
}

#[test]
fn plugin_init_creates_documented_layout() {
    let tmp = TempDir::new().unwrap();
    let project = init_project(tmp.path(), "tmp-rule");

    for f in DOCUMENTED_FILES {
        assert!(project.join(f).exists(), "missing scaffolded file: {f}");
    }

    // Rule id is derived from the project name; check() carries TODO markers.
    let lib = fs::read_to_string(project.join("src/lib.rs")).unwrap();
    assert!(
        lib.contains("TMP-RULE-001"),
        "rule id derived from project name"
    );
    assert!(
        lib.contains("TODO"),
        "check() skeleton must carry TODO markers"
    );
    assert!(lib.contains("impl AuditRule"), "must implement AuditRule");

    // README documents build → pack → install → audit with copy-pasteable commands.
    let readme = fs::read_to_string(project.join("README.md")).unwrap();
    assert!(readme.contains("cargo build --release --features plugins"));
    assert!(readme.contains("sdkt plugin pack"));
    assert!(readme.contains("sdkt plugin install"));
    assert!(readme.contains("sdkt audit path/to/contract.rs --rules"));

    // Cargo.toml is a standalone crate wired to the published sdkt-audit.
    let cargo = fs::read_to_string(project.join("Cargo.toml")).unwrap();
    assert!(
        cargo.contains("[workspace]"),
        "standalone crate must declare [workspace]"
    );
    assert!(cargo.contains("sdkt-audit"));
    assert!(cargo.contains("plugins = [\"sdkt-audit/plugins\"]"));
    assert!(cargo.contains("crate-type = [\"rlib\", \"cdylib\"]"));

    // plugin.toml is pre-staged under plugin/ for the pack/install flow, and its
    // artifact name matches the host platform (lib…so / lib….dylib / ….dll) so
    // the README commands resolve to the file `cargo build` actually produces.
    let toml = fs::read_to_string(project.join("plugin/plugin.toml")).unwrap();
    assert!(toml.contains("id = \"tmp_rule\""));
    assert!(toml.contains("kind = \"native\""));
    assert!(toml.contains("abi_major = 1"));
    let artifact = format!(
        "{}{}{}",
        std::env::consts::DLL_PREFIX,
        "tmp_rule",
        std::env::consts::DLL_SUFFIX
    );
    assert!(
        toml.contains(&format!("artifact = \"{artifact}\"")),
        "plugin.toml must name the platform-native artifact"
    );
    assert!(
        readme.contains(&format!("cp target/release/{artifact} plugin/")),
        "README build-to-audit instructions must reference the same artifact"
    );

    // The WASM manifest is pre-staged too, with kind/artifact matched.
    let wasm = fs::read_to_string(project.join("plugin-wasm/plugin.toml")).unwrap();
    assert!(wasm.contains("kind = \"wasm\""));
    assert!(wasm.contains("artifact = \"tmp_rule.wasm\""));
    assert!(
        readme.contains("--features wasm-plugins"),
        "README must document the WASM build path"
    );
}

#[test]
fn plugin_init_rejects_existing_directory() {
    let tmp = TempDir::new().unwrap();
    let project = tmp.path().join("existing");
    fs::create_dir_all(&project).unwrap();

    sdkt()
        .args(["plugin", "init", project.to_str().unwrap()])
        .assert()
        .failure()
        .stderr(predicate::str::contains("already exists"));
}

#[test]
fn plugin_init_json_output() {
    let tmp = TempDir::new().unwrap();
    let project = tmp.path().join("json_proj");

    sdkt()
        .args([
            "plugin",
            "init",
            project.to_str().unwrap(),
            "--format",
            "json",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("\"status\": \"created\""));

    assert!(project.join("src/lib.rs").exists());
}

/// The loadable artifact produced for the `tmp-rule` crate (extension varies by
/// platform: `.so` / `.dylib` / `.dll`).
fn find_cdylib_artifact(release_dir: &Path) -> Option<PathBuf> {
    let entries = fs::read_dir(release_dir).ok()?;
    for e in entries.flatten() {
        let name = e.file_name().to_string_lossy().into_owned();
        if name.contains("tmp_rule")
            && (name.ends_with(".so") || name.ends_with(".dylib") || name.ends_with(".dll"))
        {
            return Some(e.path());
        }
    }
    None
}

/// Absolute path to the in-tree `sdkt-audit` crate. The heavy test patches the
/// scaffold's `sdkt-audit` dependency to this workspace source via a cargo
/// `--config` flag, so the scaffolded templates are compiled against the API of
/// the current PR rather than the version published on crates.io (which can
/// drift behind the workspace or not yet exist for a new version).
fn sdkt_audit_patch_config() -> String {
    // CARGO_MANIFEST_DIR is the package dir (crates/sdkt-cli); `..` lands in
    // crates/, so the in-tree crate sits at /../sdkt-audit.
    let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../sdkt-audit");
    // Normalise to forward slashes so the TOML value is valid on Windows too.
    format!(
        "patch.crates-io.sdkt-audit.path=\"{}\"",
        path.replace('\\', "/")
    )
}

#[test]
fn plugin_init_scaffold_builds_and_rule_produces_finding() {
    let tmp = TempDir::new().unwrap();
    let project = init_project(tmp.path(), "tmp-rule");

    // Build the native cdylib in release mode exactly as the README instructs.
    let status = StdCommand::new("cargo")
        .arg("build")
        .arg("--release")
        .args([
            "--features",
            "plugins",
            "--config",
            &sdkt_audit_patch_config(),
        ])
        .current_dir(&project)
        .status()
        .expect("cargo build failed to execute");
    assert!(
        status.success(),
        "Scaffolded plugin failed to build with --features plugins"
    );

    let artifact = find_cdylib_artifact(&project.join("target/release"));
    assert!(
        artifact.is_some(),
        "cdylib artifact was not produced by the build"
    );

    // The scaffolded crate ships `tests/rule_test.rs` asserting the placeholder
    // rule fires (exactly one finding with the derived rule id) on a trivially
    // matching function name and stays silent otherwise. Running `cargo test`
    // therefore proves the rule is wired end-to-end, not just compiling. Same
    // project, same target dir, so the dependency graph is compiled once.
    let status = StdCommand::new("cargo")
        .arg("test")
        .args([
            "--features",
            "plugins",
            "--config",
            &sdkt_audit_patch_config(),
        ])
        .current_dir(&project)
        .status()
        .expect("cargo test failed to execute");
    assert!(
        status.success(),
        "Scaffolded plugin unit tests (finding production) failed"
    );
}

/// Verifies the scaffold resolves the published `sdkt-audit` from crates.io
/// exactly as a standalone author would experience it, with no `[patch]`.
/// Kept out of ordinary PR runs because the workspace version may not yet be
/// published; the release workflow runs it after the crates publish loop.
#[test]
#[ignore = "requires the workspace sdkt-audit version to be published on crates.io"]
fn plugin_init_scaffold_resolves_published_audit() {
    let tmp = TempDir::new().unwrap();
    let project = init_project(tmp.path(), "tmp-rule");

    let status = StdCommand::new("cargo")
        .args(["build", "--release", "--features", "plugins"])
        .current_dir(&project)
        .status()
        .expect("cargo build failed to execute");
    assert!(
        status.success(),
        "Scaffolded plugin failed to build against the published sdkt-audit"
    );
}