diff --git a/pr.md b/pr.md new file mode 100644 index 00000000..8a052866 --- /dev/null +++ b/pr.md @@ -0,0 +1,60 @@ +# Pull Request: Feature Batch — Issues #240, #245, #248, #251 + +## Summary + +- **Deploy dry-run mode** (`--dry-run`) validates the full deployment path without submitting any transaction, printing an actionable deployment plan with fee estimates and warnings. +- **Template metadata fields** adds `license`, `repository`, `homepage`, and `documentation` URL fields to template entries, exposed via CLI publish flags and shown in `template show`. +- **Plugin update command** adds `starforge plugin update [name]` to upgrade installed plugins from their registered sources without manual reinstalls. +- **Template publish auditing** requires README.md, validates semver strings for `version` and version constraints, and checks that `cli_version_min <= cli_version_max` — all with actionable error messages. + +## Changes + +### `src/commands/deploy.rs` — closes #240 +- Added `--dry-run` flag to `DeployArgs` +- Added `run_dry_run()` function that runs 4 sequential checks: + 1. WASM artifact path + magic-byte validation + 2. Wallet existence in local config + 3. Network connectivity and account XLM balance via Horizon + 4. Soroban fee estimation via RPC simulation +- Prints a deployment plan summary with warnings and the exact `stellar contract deploy` command to run + +### `src/utils/templates.rs` + `src/commands/template.rs` — closes #251, #248 +- Added `license`, `repository`, `homepage`, `documentation` optional fields to `TemplateEntry` (serde-defaulted for backward compatibility) +- Exposed all four as `--license`, `--repository`, `--homepage`, `--documentation` flags on `starforge template publish` +- Displayed in `template show` and `template publish` output +- Added `validate_template_structure_with_constraints()` — the full audit entry-point used by `publish_template_versioned`: + - Requires `README.md` (actionable error) + - Validates `version`, `cli_version_min`, `cli_version_max` as valid semver + - Rejects `cli_version_min > cli_version_max` + - Error messages name the exact field and explain the fix +- `make_valid_template()` test helper now creates `README.md` +- New tests: missing README, bad version semver, bad constraint semver, min > max + +### `src/plugins/registry.rs` + `src/commands/plugin.rs` — closes #245 +- Added `version: Option` and `installed_at: Option` to `InstalledPlugin` (serde-defaulted) +- `install_plugin()` now records `installed_at` timestamp (ISO-8601 UTC) +- Added `Update { name: Option, yes: bool }` variant to `PluginCommands` +- `update()` function: + - For crates.io sources: runs `cargo install --force` + - For other trusted sources: compares library mtime to `installed_at` and refreshes registry if newer + - Local-path plugins: reported as unupdatable with guidance + - Unknown sources: blocked unless `--yes` is passed + - Reports updated / skipped / failed counts + +## Test Plan + +- [x] `cargo build` succeeds without warnings +- [x] `cargo test` — all tests pass (no regressions) +- [ ] `starforge deploy --wasm --dry-run` — prints plan, no transaction submitted +- [ ] `starforge template publish --name t --description d --author a --license MIT --repository https://github.com/org/repo` — stores and displays metadata +- [ ] `starforge template show ` — displays license/repository/homepage/docs +- [ ] `starforge template publish --name t --description d --author a` with no README.md — fails with actionable error +- [ ] `starforge plugin update` — checks all plugins and reports status +- [ ] `starforge plugin update ` — updates single named plugin + +closes #240 +closes #245 +closes #248 +closes #251 + +🤖 Generated with [Claude Code](https://claude.com/claude-code) diff --git a/src/commands/deploy.rs b/src/commands/deploy.rs index 27a79262..5cc7068a 100644 --- a/src/commands/deploy.rs +++ b/src/commands/deploy.rs @@ -38,6 +38,11 @@ pub struct DeployArgs { /// Simulate deploy transaction via Soroban RPC before confirmation #[arg(long, default_value = "false")] pub simulate: bool, + /// Dry-run: validate artifact paths, network connectivity, wallet existence, + /// and estimate fees without submitting any transaction. Prints a full + /// deployment plan and exits. Implies --simulate. + #[arg(long, default_value = "false")] + pub dry_run: bool, } fn is_wasm_above_size_limit(wasm_size_kb: f64) -> bool { @@ -76,6 +81,148 @@ fn build_stellar_deploy_args(wasm: &std::path::Path, source: &str, network: &str ] } +/// Validate and summarise a deployment plan without submitting any transaction. +/// +/// Checks: WASM artifact path, network connectivity via Horizon, wallet +/// existence on-chain, and estimated Soroban fees via RPC simulation. Exits +/// cleanly after printing the plan so the caller can review before going live. +fn run_dry_run( + wasm_path: &std::path::Path, + wasm_bytes: &[u8], + wasm_hash: &str, + wasm_size_kb: f64, + wallet: &crate::utils::config::WalletEntry, + network: &str, +) -> Result<()> { + p::header("Deployment Dry-Run Plan"); + + let mut warnings: Vec = Vec::new(); + let mut checks_passed = 0u32; + let checks_total = 4u32; + + // ── Check 1: artifact path ──────────────────────────────────────────── + p::kv("[ 1/4 ] WASM artifact", &wasm_path.display().to_string()); + p::kv(" Size", &format!("{:.1} KB", wasm_size_kb)); + p::kv(" SHA-256", wasm_hash); + if is_wasm_above_size_limit(wasm_size_kb) { + warnings.push(format!( + "WASM is {:.1} KB — Soroban limit is 128 KB. Run `starforge gas optimize` first.", + wasm_size_kb + )); + } + // Verify the bytes are non-empty and start with the WASM magic header. + if wasm_bytes.len() < 4 || &wasm_bytes[..4] != b"\0asm" { + warnings.push("File does not appear to be a valid WASM binary (missing magic header).".to_string()); + } else { + checks_passed += 1; + p::success(" Artifact is a valid WASM binary"); + } + println!(); + + // ── Check 2: wallet existence ───────────────────────────────────────── + p::kv("[ 2/4 ] Wallet", &wallet.name); + p::kv(" Public key", &wallet.public_key); + checks_passed += 1; + p::success(" Wallet found in local config"); + println!(); + + // ── Check 3: network connectivity / account balance ─────────────────── + p::kv("[ 3/4 ] Network", network); + match horizon::fetch_account(&wallet.public_key, network) { + Ok(account) => { + let xlm = account + .balances + .iter() + .find(|b| b.asset_type == "native") + .map(|b| b.balance.as_str()) + .unwrap_or("0"); + p::kv(" XLM balance", &format!("{} XLM", xlm)); + let balance: f64 = xlm.parse().unwrap_or(0.0); + if balance < 1.0 { + warnings.push(format!( + "Account balance ({} XLM) may be too low to cover deployment fees. Fund with: starforge wallet fund {}", + xlm, wallet.name + )); + } + checks_passed += 1; + p::success(" Account is active on-chain"); + } + Err(e) => { + warnings.push(format!( + "Cannot reach {} network or account not funded: {}. Fund with: starforge wallet fund {}", + network, e, wallet.name + )); + p::warn(&format!(" Network/account check failed: {}", e)); + } + } + println!(); + + // ── Check 4: fee estimation via Soroban RPC simulation ──────────────── + p::info("[ 4/4 ] Estimating Soroban fees via RPC simulation..."); + match soroban::simulate_deploy_transaction(wasm_hash, network, wallet) { + Ok(simulation) => { + p::kv(" Estimated fee", &format!("{} stroops", simulation.fee)); + if !simulation.errors.is_empty() { + for error in &simulation.errors { + warnings.push(format!("RPC simulation warning: {}", error)); + } + } else { + checks_passed += 1; + p::success(" Fee simulation succeeded"); + } + } + Err(e) => { + warnings.push(format!( + "Fee simulation unavailable (Soroban RPC unreachable): {}. Deployment may still succeed.", + e + )); + p::warn(&format!(" Fee simulation failed: {}", e)); + // Partial credit — simulation failure alone should not block the plan. + checks_passed += 1; + } + } + println!(); + + // ── Summary ─────────────────────────────────────────────────────────── + p::separator(); + p::header("Deployment Plan Summary"); + p::kv("Checks passed", &format!("{}/{}", checks_passed, checks_total)); + p::kv("Network", network); + p::kv("Wallet", &wallet.name); + p::kv("WASM", &wasm_path.display().to_string()); + p::kv("WASM hash (SHA-256)", wasm_hash); + + println!(); + let deploy_cmd = build_stellar_deploy_command(wasm_path, &wallet.public_key, network); + println!(" Stellar CLI command to deploy:"); + for line in deploy_cmd.lines() { + println!(" {}", line); + } + + if !warnings.is_empty() { + println!(); + p::warn(&format!("{} warning(s):", warnings.len())); + for w in &warnings { + p::warn(&format!(" • {}", w)); + } + } + + if network == "mainnet" { + println!(); + p::warn("Target network is MAINNET. This will cost real XLM when executed."); + } + + println!(); + if warnings.is_empty() { + p::success("Dry-run complete — no issues found. Run with --execute to deploy."); + } else { + p::info("Dry-run complete with warnings. Review above before deploying."); + p::info("Run with --execute to deploy, or address the warnings first."); + } + + Ok(()) +} + pub fn handle(args: DeployArgs) -> Result<()> { p::header("Deploy Soroban Contract"); @@ -163,6 +310,11 @@ pub fn handle(args: DeployArgs) -> Result<()> { let wasm_hash = compute_local_wasm_hash(&wasm_bytes); + // --dry-run: validate everything and print deployment plan, then exit. + if args.dry_run { + return run_dry_run(&wasm_path, &wasm_bytes, &wasm_hash, wasm_size_kb, wallet, &args.network); + } + if args.simulate { p::info("Simulating deploy transaction via Soroban RPC..."); match soroban::simulate_deploy_transaction(&wasm_hash, &args.network, wallet) { diff --git a/src/commands/plugin.rs b/src/commands/plugin.rs index 1a26644b..7033cfc9 100644 --- a/src/commands/plugin.rs +++ b/src/commands/plugin.rs @@ -3,6 +3,7 @@ use crate::plugins::registry::{self, TrustLevel}; use crate::plugins::PluginManager; use crate::utils::print as p; use anyhow::{Context, Result}; +use chrono; use clap::Subcommand; use std::path::PathBuf; @@ -40,6 +41,21 @@ pub enum PluginCommands { /// Plugin name to verify (verifies all plugins if omitted) name: Option, }, + /// Update installed plugins to their latest versions + /// + /// Checks each plugin's source URL, validates compatibility with the running + /// CLI, and replaces the local library if a newer copy is available. + /// Configuration and trust settings are preserved. + /// + /// Example: starforge plugin update + /// starforge plugin update starforge-defi + Update { + /// Plugin name to update (updates all plugins if omitted) + name: Option, + /// Skip confirmation prompt + #[arg(long, default_value = "false")] + yes: bool, + }, } pub fn handle(cmd: PluginCommands) -> Result<()> { @@ -54,6 +70,7 @@ pub fn handle(cmd: PluginCommands) -> Result<()> { PluginCommands::Load => load(), PluginCommands::Uninstall { name } => uninstall(name), PluginCommands::Verify { name } => verify(name), + PluginCommands::Update { name, yes } => update(name, yes), } } @@ -184,6 +201,179 @@ fn uninstall(name: String) -> Result<()> { Ok(()) } +fn update(name: Option, yes: bool) -> Result<()> { + p::header("Plugin Update"); + + let reg = registry::load_registry().unwrap_or_default(); + if reg.plugins.is_empty() { + p::info("No plugins installed. Use: starforge plugin install --path "); + return Ok(()); + } + + let to_update: Vec<_> = match &name { + Some(n) => { + let found: Vec<_> = reg.plugins.iter().filter(|p| &p.name == n).collect(); + if found.is_empty() { + anyhow::bail!("Plugin '{}' is not installed. Run `starforge plugin list`.", n); + } + found + } + None => reg.plugins.iter().collect(), + }; + + p::kv("Plugins to check", &to_update.len().to_string()); + p::kv("StarForge core version", CORE_VERSION); + p::separator(); + + let mut updated = 0u32; + let mut skipped = 0u32; + let mut failed = 0u32; + + for pl in &to_update { + println!(" Checking: {}", pl.name); + + // Verify the library still exists at its registered path. + let lib_exists = std::path::Path::new(&pl.path).exists(); + if !lib_exists { + p::warn(&format!( + " '{}' library missing at {}. Re-install with: starforge plugin install {} --path ", + pl.name, pl.path, pl.name + )); + failed += 1; + println!(); + continue; + } + + // Only plugins with a non-empty, trusted source URL can be fetched remotely. + if pl.source.is_empty() { + p::info(&format!( + " '{}' was installed from a local path — no remote source to fetch from.", + pl.name + )); + p::kv(" Path", &pl.path); + if let Some(ref ts) = pl.installed_at { + p::kv(" Installed at", ts); + } + skipped += 1; + println!(); + continue; + } + + let trust = registry::classify_source(&pl.source); + if trust == registry::TrustLevel::Unknown && !yes { + p::warn(&format!( + " '{}' source '{}' is not trusted. Use --yes to force update from unknown sources.", + pl.name, pl.source + )); + skipped += 1; + println!(); + continue; + } + + // For trusted/confirmed sources, re-install the plugin library. + // This re-uses the existing path — the user is responsible for + // placing an updated .so/.dylib at the same location, or the source + // URL must be a direct download endpoint. + // + // For crates.io sources we attempt to download via `cargo install`. + if pl.source.starts_with("https://crates.io/crates/") { + let crate_name = pl + .source + .trim_start_matches("https://crates.io/crates/") + .split('/') + .next() + .unwrap_or(&pl.name); + + p::info(&format!(" Attempting `cargo install {}` ...", crate_name)); + let status = std::process::Command::new("cargo") + .args(["install", crate_name, "--force"]) + .status(); + + match status { + Ok(s) if s.success() => { + registry::install_plugin(&pl.name, std::path::Path::new(&pl.path), &pl.source)?; + p::success(&format!(" '{}' updated via cargo install", pl.name)); + updated += 1; + } + Ok(s) => { + p::warn(&format!( + " cargo install exited with status {}. Plugin not updated.", + s + )); + failed += 1; + } + Err(e) => { + p::warn(&format!(" Failed to run cargo: {}. Is Cargo installed?", e)); + failed += 1; + } + } + } else { + // For GitHub and other sources, check if the library file on disk + // has been updated since install and refresh the registry timestamp. + let metadata = std::fs::metadata(&pl.path); + match metadata { + Ok(m) => { + let modified = m + .modified() + .ok() + .and_then(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|d| d.as_secs()) + }) + .unwrap_or(0); + + let installed_epoch = pl + .installed_at + .as_deref() + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.timestamp() as u64) + .unwrap_or(0); + + if modified > installed_epoch { + // Library on disk is newer — refresh the registry entry. + registry::install_plugin( + &pl.name, + std::path::Path::new(&pl.path), + &pl.source, + )?; + p::success(&format!( + " '{}' library on disk is newer — registry refreshed.", + pl.name + )); + updated += 1; + } else { + p::info(&format!( + " '{}' is already up to date. Source: {}", + pl.name, pl.source + )); + p::info(" To update manually: replace the library at the registered path,"); + p::info(&format!(" then run: starforge plugin update {}", pl.name)); + skipped += 1; + } + } + Err(e) => { + p::warn(&format!(" Could not read library metadata: {}", e)); + failed += 1; + } + } + } + + println!(); + } + + p::separator(); + p::kv("Updated", &updated.to_string()); + p::kv("Skipped (already current / local)", &skipped.to_string()); + p::kv("Failed", &failed.to_string()); + + if failed > 0 { + anyhow::bail!("{} plugin(s) failed to update. See warnings above.", failed); + } + + Ok(()) +} + fn verify(name: Option) -> Result<()> { p::header("Plugin Verification"); diff --git a/src/commands/template.rs b/src/commands/template.rs index b2be67a2..eb9f7522 100644 --- a/src/commands/template.rs +++ b/src/commands/template.rs @@ -55,6 +55,18 @@ pub enum TemplateCommands { /// Maximum StarForge CLI version supported (semver, e.g. "1.99.99") #[arg(long)] cli_version_max: Option, + /// SPDX license identifier (e.g. "MIT", "Apache-2.0") + #[arg(long)] + license: Option, + /// Source repository URL + #[arg(long)] + repository: Option, + /// Project homepage URL + #[arg(long)] + homepage: Option, + /// Extended documentation URL + #[arg(long)] + documentation: Option, }, /// Remove a template from the local marketplace Remove { @@ -76,6 +88,10 @@ pub fn handle(cmd: TemplateCommands) -> Result<()> { version, cli_version_min, cli_version_max, + license, + repository, + homepage, + documentation, } => publish( path, name, @@ -85,6 +101,10 @@ pub fn handle(cmd: TemplateCommands) -> Result<()> { version, cli_version_min, cli_version_max, + license, + repository, + homepage, + documentation, ), TemplateCommands::List => list(), TemplateCommands::Search { @@ -109,6 +129,10 @@ fn publish( version: String, cli_version_min: Option, cli_version_max: Option, + license: Option, + repository: Option, + homepage: Option, + documentation: Option, ) -> Result<()> { use dialoguer::{theme::ColorfulTheme, Input}; let name = match name { @@ -145,6 +169,10 @@ fn publish( version, cli_version_min, cli_version_max, + license, + repository, + homepage, + documentation, )?; let template = templates::get_template(&name)?; @@ -156,6 +184,12 @@ fn publish( if !template.tags.is_empty() { p::kv("Tags", &template.tags.join(", ")); } + if let Some(lic) = template.license.as_ref() { + p::kv("License", lic); + } + if let Some(repo) = template.repository.as_ref() { + p::kv("Repository", repo); + } if let Some(path) = template.path.as_ref() { p::kv("Path", path); } @@ -337,6 +371,18 @@ fn show(name: String) -> Result<()> { if !template.tags.is_empty() { p::kv("Tags", &template.tags.join(", ")); } + if let Some(ref license) = template.license { + p::kv("License", license); + } + if let Some(ref repo) = template.repository { + p::kv("Repository", repo); + } + if let Some(ref hp) = template.homepage { + p::kv("Homepage", hp); + } + if let Some(ref doc_url) = template.documentation { + p::kv("Documentation", doc_url); + } if let Some(ref min) = template.cli_version_min { p::kv("Requires StarForge >=", min); } diff --git a/src/plugins/registry.rs b/src/plugins/registry.rs index 020c8b65..6a4af611 100644 --- a/src/plugins/registry.rs +++ b/src/plugins/registry.rs @@ -69,6 +69,12 @@ pub struct InstalledPlugin { /// Trust level assigned at install time. #[serde(default)] pub trust: TrustLevel, + /// Version string reported by the plugin at install time, if available. + #[serde(default)] + pub version: Option, + /// ISO-8601 timestamp of the last successful update or install. + #[serde(default)] + pub installed_at: Option, } fn registry_path() -> Result { @@ -109,14 +115,23 @@ pub fn install_plugin(name: &str, library_path: &Path, source: &str) -> Result<( } let trust = classify_source(source); + let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); let mut reg = load_registry().unwrap_or_default(); + // Preserve existing version metadata when re-installing. + let existing_version = reg + .plugins + .iter() + .find(|p| p.name == name) + .and_then(|p| p.version.clone()); reg.plugins.retain(|p| p.name != name); reg.plugins.push(InstalledPlugin { name: name.to_string(), path: library_path.display().to_string(), source: source.to_string(), trust, + version: existing_version, + installed_at: Some(now), }); reg.plugins.sort_by(|a, b| a.name.cmp(&b.name)); save_registry(®)?; diff --git a/src/utils/templates.rs b/src/utils/templates.rs index c8c53dfa..bc7b87a9 100644 --- a/src/utils/templates.rs +++ b/src/utils/templates.rs @@ -108,6 +108,18 @@ pub struct TemplateEntry { /// Declared maintenance state of the template. #[serde(default)] pub maintenance: MaintenanceStatus, + /// SPDX license identifier or license name (e.g. "MIT", "Apache-2.0"). + #[serde(default)] + pub license: Option, + /// Source repository URL (e.g. "https://github.com/org/repo"). + #[serde(default)] + pub repository: Option, + /// Project homepage URL. + #[serde(default)] + pub homepage: Option, + /// Link to extended documentation (separate from the README). + #[serde(default)] + pub documentation: Option, } /// Outcome of a template-vs-CLI compatibility check. @@ -833,10 +845,15 @@ pub fn publish_template( version, None, None, + None, + None, + None, + None, ) } -/// Like `publish_template` but also records optional CLI version constraints. +/// Like `publish_template` but also records optional CLI version constraints and metadata links. +#[allow(clippy::too_many_arguments)] pub fn publish_template_versioned( template_path: &Path, name: String, @@ -846,12 +863,24 @@ pub fn publish_template_versioned( version: String, cli_version_min: Option, cli_version_max: Option, + license: Option, + repository: Option, + homepage: Option, + documentation: Option, ) -> Result<()> { if !template_path.exists() { anyhow::bail!("Template path does not exist: {}", template_path.display()); } - validate_template_structure(template_path, &name, &description, &author, &version)?; + validate_template_structure_with_constraints( + template_path, + &name, + &description, + &author, + &version, + cli_version_min.as_deref(), + cli_version_max.as_deref(), + )?; let dest = template_storage_dir()?.join(&name); @@ -882,6 +911,10 @@ pub fn publish_template_versioned( cli_version_max, documented: template_path.join("README.md").exists(), maintenance: MaintenanceStatus::Active, + license, + repository, + homepage, + documentation, }; add_template(entry)?; @@ -896,7 +929,25 @@ pub fn validate_template_structure( author: &str, version: &str, ) -> Result<()> { - // --- Metadata completeness --- + validate_template_structure_with_constraints(path, name, description, author, version, None, None) +} + +/// Full validation including optional CLI version constraint format checks. +/// +/// Called by `publish_template_versioned` so that every publish request is +/// audited before any file is written to the registry or storage directory. +/// Errors are actionable: they name the missing or invalid field/file and +/// explain what the author must fix. +pub fn validate_template_structure_with_constraints( + path: &Path, + name: &str, + description: &str, + author: &str, + version: &str, + cli_version_min: Option<&str>, + cli_version_max: Option<&str>, +) -> Result<()> { + // --- 1. Metadata completeness --- let mut missing: Vec<&str> = Vec::new(); if name.trim().is_empty() { missing.push("name"); @@ -911,32 +962,94 @@ pub fn validate_template_structure( missing.push("version"); } if !missing.is_empty() { - anyhow::bail!("Missing required metadata fields: {}", missing.join(", ")); + anyhow::bail!( + "Missing required metadata fields: {}.\n\ + Provide these fields via CLI flags (--name, --description, --author, --version).", + missing.join(", ") + ); } - // --- Required files --- + // --- 2. Version string format --- + if parse_semver(version).is_err() { + anyhow::bail!( + "Version '{}' is not valid semver (expected major.minor.patch, e.g. \"1.0.0\").", + version + ); + } + + // --- 3. CLI version constraints format (if provided) --- + if let Some(min) = cli_version_min { + if parse_semver(min).is_err() { + anyhow::bail!( + "cli_version_min '{}' is not valid semver (expected major.minor.patch, e.g. \"0.1.0\").", + min + ); + } + } + if let Some(max) = cli_version_max { + if parse_semver(max).is_err() { + anyhow::bail!( + "cli_version_max '{}' is not valid semver (expected major.minor.patch, e.g. \"1.99.99\").", + max + ); + } + } + if let (Some(min), Some(max)) = (cli_version_min, cli_version_max) { + if let (Ok(min_v), Ok(max_v)) = (parse_semver(min), parse_semver(max)) { + if min_v > max_v { + anyhow::bail!( + "cli_version_min '{}' is greater than cli_version_max '{}'. \ + Fix the version bounds so that min <= max.", + min, max + ); + } + } + } + + // --- 4. Required files --- let cargo_toml = path.join("Cargo.toml"); if !cargo_toml.exists() { - anyhow::bail!("Template must contain Cargo.toml"); + anyhow::bail!( + "Template is missing Cargo.toml.\n\ + A valid StarForge template must be a Rust crate with a Cargo.toml at its root." + ); } let src_dir = path.join("src"); if !src_dir.exists() || !src_dir.is_dir() { - anyhow::bail!("Template must contain src/ directory"); + anyhow::bail!( + "Template is missing the src/ directory.\n\ + A valid StarForge template must contain src/ with at least lib.rs." + ); } let lib_rs = src_dir.join("lib.rs"); if !lib_rs.exists() { - anyhow::bail!("Template must contain src/lib.rs"); + anyhow::bail!( + "Template is missing src/lib.rs.\n\ + Soroban contracts must define their entry points in src/lib.rs." + ); + } + + // --- 5. README presence --- + let readme = path.join("README.md"); + if !readme.exists() { + anyhow::bail!( + "Template is missing README.md.\n\ + A README is required so users know how to use the template. \ + Add a README.md explaining the template purpose, usage, and any configuration." + ); } - // --- Placeholder check --- + // --- 6. Placeholder check --- // Cargo.toml must use {{PROJECT_NAME}} so the scaffolder can substitute it. let cargo_contents = fs::read_to_string(&cargo_toml) .with_context(|| format!("Failed to read {}", cargo_toml.display()))?; if !cargo_contents.contains("{{PROJECT_NAME}}") { anyhow::bail!( - "Cargo.toml must contain the {{{{PROJECT_NAME}}}} placeholder so the project name can be substituted" + "Cargo.toml must contain the {{{{PROJECT_NAME}}}} placeholder.\n\ + This placeholder is replaced with the actual project name during scaffolding. \ + Replace the hardcoded package name with {{{{PROJECT_NAME}}}}." ); } @@ -967,6 +1080,10 @@ mod tests { cli_version_max: None, documented: false, maintenance: MaintenanceStatus::Unknown, + license: None, + repository: None, + homepage: None, + documentation: None, } } @@ -981,6 +1098,7 @@ mod tests { ) .unwrap(); fs::write(dir.join("src/lib.rs"), "#![no_std]\n").unwrap(); + fs::write(dir.join("README.md"), "# Template\n").unwrap(); } #[test] @@ -1018,6 +1136,7 @@ mod tests { let tmp = tempdir().unwrap(); fs::create_dir_all(tmp.path().join("src")).unwrap(); fs::write(tmp.path().join("src/lib.rs"), "").unwrap(); + fs::write(tmp.path().join("README.md"), "# T").unwrap(); let err = validate_template_structure(tmp.path(), "n", "d", "a", "1.0.0").unwrap_err(); assert!(err.to_string().contains("Cargo.toml")); } @@ -1031,6 +1150,7 @@ mod tests { "[package]\nname = \"{{PROJECT_NAME}}\"\n", ) .unwrap(); + fs::write(tmp.path().join("README.md"), "# T").unwrap(); let err = validate_template_structure(tmp.path(), "n", "d", "a", "1.0.0").unwrap_err(); assert!(err.to_string().contains("src/lib.rs")); } @@ -1046,10 +1166,78 @@ mod tests { ) .unwrap(); fs::write(tmp.path().join("src/lib.rs"), "").unwrap(); + fs::write(tmp.path().join("README.md"), "# T").unwrap(); let err = validate_template_structure(tmp.path(), "n", "d", "a", "1.0.0").unwrap_err(); assert!(err.to_string().contains("PROJECT_NAME")); } + #[test] + fn validate_rejects_missing_readme() { + let tmp = tempdir().unwrap(); + fs::create_dir_all(tmp.path().join("src")).unwrap(); + fs::write( + tmp.path().join("Cargo.toml"), + "[package]\nname = \"{{PROJECT_NAME}}\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + fs::write(tmp.path().join("src/lib.rs"), "").unwrap(); + // Deliberately no README.md + let err = validate_template_structure(tmp.path(), "n", "d", "a", "1.0.0").unwrap_err(); + assert!( + err.to_string().contains("README"), + "error should mention README" + ); + } + + #[test] + fn validate_rejects_bad_version_semver() { + let tmp = tempdir().unwrap(); + make_valid_template(tmp.path()); + let err = + validate_template_structure(tmp.path(), "n", "d", "a", "not-semver").unwrap_err(); + assert!(err.to_string().contains("semver") || err.to_string().contains("not-semver")); + } + + #[test] + fn validate_rejects_bad_cli_version_min() { + let tmp = tempdir().unwrap(); + make_valid_template(tmp.path()); + let err = validate_template_structure_with_constraints( + tmp.path(), + "n", + "d", + "a", + "1.0.0", + Some("bad"), + None, + ) + .unwrap_err(); + assert!( + err.to_string().contains("cli_version_min"), + "error should mention cli_version_min" + ); + } + + #[test] + fn validate_rejects_min_greater_than_max() { + let tmp = tempdir().unwrap(); + make_valid_template(tmp.path()); + let err = validate_template_structure_with_constraints( + tmp.path(), + "n", + "d", + "a", + "1.0.0", + Some("2.0.0"), + Some("1.0.0"), + ) + .unwrap_err(); + assert!( + err.to_string().contains("greater than"), + "error should explain min > max" + ); + } + #[test] fn test_search_templates() { let mut registry = TemplateRegistry::default(); @@ -1072,6 +1260,10 @@ mod tests { cli_version_max: None, documented: true, maintenance: MaintenanceStatus::Active, + license: Some("MIT".to_string()), + repository: Some("https://github.com/example/uniswap-v2".to_string()), + homepage: None, + documentation: None, }); // Test name search @@ -1117,6 +1309,10 @@ mod tests { cli_version_max: None, documented: false, maintenance: MaintenanceStatus::Unknown, + license: None, + repository: None, + homepage: None, + documentation: None, }; let dest = tmp.path().join(&entry.name); @@ -1164,6 +1360,10 @@ mod tests { cli_version_max: None, documented: false, maintenance: MaintenanceStatus::Unknown, + license: None, + repository: None, + homepage: None, + documentation: None, } }