From 6cb84b8c4456cbcca1ceabb9beea386f1a0fe272 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 19 May 2026 16:39:25 -0400 Subject: [PATCH 1/2] test(cli): foundation for CLI-contract unit-test campaign Sets up the foundation for a comprehensive unit-test campaign on the socket-patch CLI. No behavior change to the binary. Two changes: 1. Expose the clap parser as a library so integration tests can verify the public CLI contract without spawning the binary: - new crates/socket-patch-cli/src/lib.rs hosting Cli, Commands, looks_like_uuid, and parse_with_uuid_fallback (extracted from main.rs) - Cargo.toml gains [lib] entry alongside the existing [[bin]] - main.rs is now a thin wrapper that delegates to the lib 2. De-duplicate the manifest-path resolution block that was copy-pasted into 5 commands (apply, rollback, list, remove, repair). New helper socket_patch_core::manifest::operations::resolve_manifest_path handles the absolute-vs-relative join in one place, with 3 unit tests. 3. New CLI_CONTRACT.md next to the crate documenting every subcommand, flag, default, alias, JSON shape, and exit code as semver-significant surface. Adds a comment block above pub enum Commands pointing to it so anyone editing the parser sees the contract reminder. Verified: cargo build/clippy/test --workspace --all-features all clean (415 unit tests pass, including 3 new resolve_manifest_path tests). Foundation for follow-up PRs that add the per-command parser snapshot tests and helper unit tests. Assisted-by: Claude Code:claude-opus-4-7 --- crates/socket-patch-cli/CLI_CONTRACT.md | 266 ++++++++++++++++++ crates/socket-patch-cli/Cargo.toml | 4 + crates/socket-patch-cli/src/commands/apply.rs | 8 +- crates/socket-patch-cli/src/commands/list.rs | 10 +- .../socket-patch-cli/src/commands/remove.rs | 10 +- .../socket-patch-cli/src/commands/repair.rs | 8 +- .../socket-patch-cli/src/commands/rollback.rs | 8 +- crates/socket-patch-cli/src/lib.rs | 96 +++++++ crates/socket-patch-cli/src/main.rs | 79 +----- .../src/manifest/operations.rs | 39 ++- 10 files changed, 421 insertions(+), 107 deletions(-) create mode 100644 crates/socket-patch-cli/CLI_CONTRACT.md create mode 100644 crates/socket-patch-cli/src/lib.rs diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md new file mode 100644 index 0000000..df3bdd9 --- /dev/null +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -0,0 +1,266 @@ +# socket-patch CLI contract + +This document defines the **public surface** of the `socket-patch` binary. Anything listed here is part of the user-visible contract: third-party scripts, CI pipelines, and the npm/pypi/cargo wrappers depend on it. Changes are governed by the semver policy at the bottom of this file. + +> **Why this exists.** Until late 2026 the CLI crate had zero unit tests under `src/` — only network-dependent `tests/e2e_*.rs` suites that run with `--ignored`. A flag rename, a default-value change, or a JSON key rename could land green and break every shipped wrapper silently. The contract below is now backed by the unit tests under `crates/socket-patch-cli/src/**` (`#[cfg(test)] mod tests`) and the parser tests under `crates/socket-patch-cli/tests/cli_parse_*.rs`. Changes that violate the contract must update those tests in lock-step with a major version bump. + +## Subcommands + +| Name | Visible alias(es) | Notes | +|---|---|---| +| `apply` | — | Apply patches from the local manifest | +| `rollback` | — | Restore original files; takes optional positional `identifier` | +| `get` | `download` | Fetch + apply patch; requires positional `identifier` | +| `scan` | — | Crawl installed packages for available patches | +| `list` | — | Print patches in the local manifest | +| `remove` | — | Remove patch from manifest (rolls back first); requires positional `identifier` | +| `setup` | — | Configure package.json postinstall scripts | +| `repair` | `gc` | Download missing blobs + clean up unused ones | + +**Bare-UUID fallback.** `socket-patch ` is rewritten to `socket-patch get `. The UUID shape checked is the standard 8-4-4-4-12 hex pattern (case-insensitive). See [`src/lib.rs::looks_like_uuid`](src/lib.rs). + +## Flags — long and short forms + +Every flag below is part of the contract. The default values are pinned by parser tests. + +### `apply` + +| Long | Short | Default | Type | +|---|---|---|---| +| `--cwd` | — | `.` | path | +| `--dry-run` | `-d` | `false` | bool | +| `--silent` | `-s` | `false` | bool | +| `--manifest-path` | `-m` | `.socket/manifest.json` | string | +| `--offline` | — | `false` | bool | +| `--global` | `-g` | `false` | bool | +| `--global-prefix` | — | (none) | path | +| `--ecosystems` | — | (none) | CSV → `Vec` | +| `--force` | `-f` | `false` | bool | +| `--json` | — | `false` | bool | +| `--verbose` | `-v` | `false` | bool | +| `--download-mode` | — | **`diff`** | string | + +### `rollback` + +Same as `apply` plus: `--one-off` (bool), `--org` (string), `--api-url` (string), `--api-token` (string). Positional `identifier` is **optional** (omit to rollback everything). + +### `get` + +Required positional `identifier`. Flags: + +| Long | Short | Alias | Default | Type | +|---|---|---|---|---| +| `--org` | — | — | (none) | string | +| `--cwd` | — | — | `.` | path | +| `--id` | — | — | `false` | bool | +| `--cve` | — | — | `false` | bool | +| `--ghsa` | — | — | `false` | bool | +| `--package` | `-p` | — | `false` | bool | +| `--yes` | `-y` | — | `false` | bool | +| `--api-url` | — | — | (none) | string | +| `--api-token` | — | — | (none) | string | +| `--save-only` | — | **`--no-apply`** | `false` | bool | +| `--global` | `-g` | — | `false` | bool | +| `--global-prefix` | — | — | (none) | path | +| `--one-off` | — | — | `false` | bool | +| `--json` | — | — | `false` | bool | +| `--download-mode` | — | — | **`diff`** | string | + +The hidden alias `--no-apply` on `--save-only` is **part of the contract** — it does not appear in `--help` but is widely used in existing scripts. + +### `scan` + +| Long | Short | Default | Type | +|---|---|---|---| +| `--cwd` | — | `.` | path | +| `--org` | — | (none) | string | +| `--json` | — | `false` | bool | +| `--yes` | `-y` | `false` | bool | +| `--global` | `-g` | `false` | bool | +| `--global-prefix` | — | (none) | path | +| `--batch-size` | — | **`100`** | usize | +| `--api-url` | — | (none) | string | +| `--api-token` | — | (none) | string | +| `--ecosystems` | — | (none) | CSV → `Vec` | +| `--download-mode` | — | **`diff`** | string | + +### `list` + +| Long | Short | Default | Type | +|---|---|---|---| +| `--cwd` | — | `.` | path | +| `--manifest-path` | `-m` | `.socket/manifest.json` | string | +| `--json` | — | `false` | bool | + +### `remove` + +Required positional `identifier`. Flags: + +| Long | Short | Default | Type | +|---|---|---|---| +| `--cwd` | — | `.` | path | +| `--manifest-path` | `-m` | `.socket/manifest.json` | string | +| `--skip-rollback` | — | `false` | bool | +| `--yes` | `-y` | `false` | bool | +| `--global` | `-g` | `false` | bool | +| `--global-prefix` | — | (none) | path | +| `--json` | — | `false` | bool | + +### `setup` + +| Long | Short | Default | Type | +|---|---|---|---| +| `--cwd` | — | `.` | path | +| `--dry-run` | `-d` | `false` | bool | +| `--yes` | `-y` | `false` | bool | +| `--json` | — | `false` | bool | + +### `repair` + +| Long | Short | Default | Type | +|---|---|---|---| +| `--cwd` | — | `.` | path | +| `--manifest-path` | `-m` | `.socket/manifest.json` | string | +| `--dry-run` | `-d` | `false` | bool | +| `--offline` | — | `false` | bool | +| `--download-only` | — | `false` | bool | +| `--json` | — | `false` | bool | +| `--download-mode` | — | **`file`** | string | + +**Note:** `repair`'s `--download-mode` default differs from every other command (`file` vs `diff`). This is intentional — repair restores legacy per-file blobs needed to apply any patch. + +## CSV value parsing + +`--ecosystems` on `apply`, `rollback`, and `scan` uses clap's `value_delimiter = ','`. Input `--ecosystems npm,pypi,cargo` becomes `vec!["npm", "pypi", "cargo"]`. Switching to space-separated or dropping the delimiter is a **breaking** change. + +## JSON output shapes + +When `--json` is set, commands print a single JSON object to stdout. The schemas below are stable. + +### Missing-manifest error (`apply`/`list`/`remove`/`repair`/`rollback`) + +```json +{ + "status": "error", + "error": "Manifest not found", + "path": "" +} +``` + +### Invalid-manifest error + +```json +{ "status": "error", "error": "Invalid manifest" } +``` + +### Generic error + +```json +{ "status": "error", "error": "" } +``` + +### `list` success — empty manifest + +```json +{ "status": "success", "patches": [] } +``` + +### `list` success — populated + +```json +{ + "status": "success", + "patches": [ + { + "purl": "pkg:npm/foo@1.2.3", + "uuid": "…", + "exportedAt": "…", + "tier": "free|paid", + "license": "…", + "description": "…", + "files": ["…"], + "vulnerabilities": [ + { "id": "…", "cves": ["…"], "summary": "…", "severity": "…", "description": "…" } + ] + } + ] +} +``` + +### `setup` — no package.json files found + +```json +{ + "status": "no_files", + "updated": 0, + "alreadyConfigured": 0, + "errors": 0, + "files": [] +} +``` + +### `get` — multiple-patch selection required (JSON mode) + +```json +{ + "status": "selection_required", + "error": "Multiple patches available for . Specify --id to select one.", + "purl": "", + "options": [ + { "uuid": "…", "tier": "…", "published_at": "…", "description": "…", "vulnerabilities": [ … ] } + ] +} +``` + +## Exit codes + +| Code | Meaning | +|---|---| +| `0` | Success | +| `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, etc.) | + +`list` returns **`0`** for an empty manifest and **`1`** for a missing manifest — these are distinct and load-bearing. + +## Semver policy + +Versioning lives in **`Cargo.toml`** at the workspace root (`version = "..."`) and is propagated to npm, pypi, and cargo wrappers by **`scripts/version-sync.sh `**. + +| Change | Bump | +|---|---| +| Rename or remove a subcommand | **MAJOR** | +| Rename or remove a visible alias (`download`, `gc`) | **MAJOR** | +| Rename or remove a hidden alias (`--no-apply`) | **MAJOR** | +| Rename, remove, or change short form of a flag (`-d`, `-m`, etc.) | **MAJOR** | +| Change a default value (`--download-mode`, `--batch-size`, `--manifest-path`, …) | **MAJOR** | +| Change an exit code's meaning or add a new non-zero code with different semantics | **MAJOR** | +| Rename a JSON output key or change a `status` string | **MAJOR** | +| Remove a JSON output key | **MAJOR** | +| Drop the bare-UUID fallback | **MAJOR** | +| Add a *required* new flag | **MAJOR** | +| Add a new subcommand | **MINOR** | +| Add a new optional flag | **MINOR** | +| Add a new optional JSON output key (additive) | **MINOR** | +| Add a new visible alias to an existing subcommand | **MINOR** | +| Fix a bug without changing any of the above | **PATCH** | + +After bumping `Cargo.toml`, run: + +```bash +scripts/version-sync.sh +``` + +This syncs the workspace package version into: + +- `npm/socket-patch/package.json` (and its `optionalDependencies`) +- every per-platform `npm/socket-patch-*/package.json` +- `pypi/socket-patch/pyproject.toml` + +## How the contract is enforced + +Every item in this document is locked in by at least one of: + +- **clap parser snapshots** in `crates/socket-patch-cli/tests/cli_parse_*.rs` — assert flag names, short forms, defaults, aliases, and CSV delimiters by calling `socket_patch_cli::Cli::try_parse_from(...)`. +- **Helper unit tests** in `crates/socket-patch-cli/src/**` (`#[cfg(test)] mod tests` blocks) — cover `looks_like_uuid`, `parse_with_uuid_fallback`, `detect_identifier_type`, `select_patches`, `find_patches_to_rollback`, `partition_purls`, `verify_status_str`, `format_severity`, `color`, and the JSON serializers. +- **Async `run()` integration tests** in `tests/cli_parse_list.rs`, `tests/cli_parse_remove.rs`, `tests/cli_parse_setup.rs` — exercise the no-network error paths and assert JSON shape via `serde_json::from_str::` + per-key assertions. + +If you add a new flag/subcommand/JSON key, add a test here that locks the new surface in the same PR. diff --git a/crates/socket-patch-cli/Cargo.toml b/crates/socket-patch-cli/Cargo.toml index 8911c79..ed2a651 100644 --- a/crates/socket-patch-cli/Cargo.toml +++ b/crates/socket-patch-cli/Cargo.toml @@ -7,6 +7,10 @@ license.workspace = true repository.workspace = true readme = "README.md" +[lib] +name = "socket_patch_cli" +path = "src/lib.rs" + [[bin]] name = "socket-patch" path = "src/main.rs" diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 80f4f67..0f88fe1 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -6,7 +6,7 @@ use socket_patch_core::api::blob_fetcher::{ use socket_patch_core::api::client::get_api_client_from_env; use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; -use socket_patch_core::manifest::operations::read_manifest; +use socket_patch_core::manifest::operations::{read_manifest, resolve_manifest_path}; use socket_patch_core::patch::apply::{ apply_package_patch, verify_file_patch, ApplyResult, PatchSources, VerifyStatus, }; @@ -113,11 +113,7 @@ pub async fn run(args: ApplyArgs) -> i32 { let api_token = telemetry_client.api_token().cloned(); let org_slug = telemetry_client.org_slug().cloned(); - let manifest_path = if Path::new(&args.manifest_path).is_absolute() { - PathBuf::from(&args.manifest_path) - } else { - args.cwd.join(&args.manifest_path) - }; + let manifest_path = resolve_manifest_path(&args.cwd, &args.manifest_path); // Check if manifest exists - exit successfully if no .socket folder is set up if tokio::fs::metadata(&manifest_path).await.is_err() { diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index 8dc00a6..9365537 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -1,7 +1,7 @@ use clap::Args; use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; -use socket_patch_core::manifest::operations::read_manifest; -use std::path::{Path, PathBuf}; +use socket_patch_core::manifest::operations::{read_manifest, resolve_manifest_path}; +use std::path::PathBuf; #[derive(Args)] pub struct ListArgs { @@ -19,11 +19,7 @@ pub struct ListArgs { } pub async fn run(args: ListArgs) -> i32 { - let manifest_path = if Path::new(&args.manifest_path).is_absolute() { - PathBuf::from(&args.manifest_path) - } else { - args.cwd.join(&args.manifest_path) - }; + let manifest_path = resolve_manifest_path(&args.cwd, &args.manifest_path); // Check if manifest exists if tokio::fs::metadata(&manifest_path).await.is_err() { diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 8acff80..1d5c203 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -1,6 +1,8 @@ use clap::Args; use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; -use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; +use socket_patch_core::manifest::operations::{ + read_manifest, resolve_manifest_path, write_manifest, +}; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::utils::cleanup_blobs::{cleanup_unused_blobs, format_cleanup_result}; use socket_patch_core::utils::telemetry::{track_patch_removed, track_patch_remove_failed}; @@ -49,11 +51,7 @@ pub async fn run(args: RemoveArgs) -> i32 { let api_token = telemetry_client.api_token().cloned(); let org_slug = telemetry_client.org_slug().cloned(); - let manifest_path = if Path::new(&args.manifest_path).is_absolute() { - PathBuf::from(&args.manifest_path) - } else { - args.cwd.join(&args.manifest_path) - }; + let manifest_path = resolve_manifest_path(&args.cwd, &args.manifest_path); if tokio::fs::metadata(&manifest_path).await.is_err() { if args.json { diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 2197bb1..ff54e07 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -5,7 +5,7 @@ use socket_patch_core::api::blob_fetcher::{ }; use socket_patch_core::api::client::get_api_client_from_env; use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; -use socket_patch_core::manifest::operations::read_manifest; +use socket_patch_core::manifest::operations::{read_manifest, resolve_manifest_path}; use socket_patch_core::patch::apply::PatchSources; use socket_patch_core::utils::cleanup_blobs::{ cleanup_unused_archives, cleanup_unused_blobs, format_cleanup_result, @@ -46,11 +46,7 @@ pub struct RepairArgs { } pub async fn run(args: RepairArgs) -> i32 { - let manifest_path = if Path::new(&args.manifest_path).is_absolute() { - PathBuf::from(&args.manifest_path) - } else { - args.cwd.join(&args.manifest_path) - }; + let manifest_path = resolve_manifest_path(&args.cwd, &args.manifest_path); if tokio::fs::metadata(&manifest_path).await.is_err() { if args.json { diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 93bf96f..3de0194 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -5,7 +5,7 @@ use socket_patch_core::api::blob_fetcher::{ use socket_patch_core::api::client::get_api_client_from_env; use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; use socket_patch_core::crawlers::CrawlerOptions; -use socket_patch_core::manifest::operations::read_manifest; +use socket_patch_core::manifest::operations::{read_manifest, resolve_manifest_path}; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::rollback::{rollback_package_patch, RollbackResult, VerifyRollbackStatus}; use socket_patch_core::utils::telemetry::{track_patch_rolled_back, track_patch_rollback_failed}; @@ -212,11 +212,7 @@ pub async fn run(args: RollbackArgs) -> i32 { return 1; } - let manifest_path = if Path::new(&args.manifest_path).is_absolute() { - PathBuf::from(&args.manifest_path) - } else { - args.cwd.join(&args.manifest_path) - }; + let manifest_path = resolve_manifest_path(&args.cwd, &args.manifest_path); if tokio::fs::metadata(&manifest_path).await.is_err() { if args.json { diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs new file mode 100644 index 0000000..a06cf5c --- /dev/null +++ b/crates/socket-patch-cli/src/lib.rs @@ -0,0 +1,96 @@ +//! socket-patch CLI library crate. +//! +//! Exposes the clap parser types so integration tests can verify the public +//! CLI contract without invoking the binary. The `main.rs` binary entry point +//! is a thin wrapper that delegates to [`parse_with_uuid_fallback`] and the +//! `run` function on each command's `Args`. + +pub mod commands; +pub mod ecosystem_dispatch; +pub mod output; + +use clap::{Parser, Subcommand}; + +// CLI contract surface — subcommand names, visible_alias values, flag names, +// defaults, JSON shapes, and exit codes are PUBLIC and SEMVER-SIGNIFICANT. +// Changes here require a MAJOR bump + `scripts/version-sync.sh`. +// See crates/socket-patch-cli/CLI_CONTRACT.md. +#[derive(Parser)] +#[command( + name = "socket-patch", + about = "CLI tool for applying security patches to dependencies", + version, + propagate_version = true +)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Apply security patches to dependencies + Apply(commands::apply::ApplyArgs), + + /// Rollback patches to restore original files + Rollback(commands::rollback::RollbackArgs), + + /// Get security patches from Socket API and apply them + #[command(visible_alias = "download")] + Get(commands::get::GetArgs), + + /// Scan installed packages for available security patches + Scan(commands::scan::ScanArgs), + + /// List all patches in the local manifest + List(commands::list::ListArgs), + + /// Remove a patch from the manifest by PURL or UUID (rolls back files first) + Remove(commands::remove::RemoveArgs), + + /// Configure package.json postinstall scripts to apply patches + Setup(commands::setup::SetupArgs), + + /// Download missing blobs and clean up unused blobs + #[command(visible_alias = "gc")] + Repair(commands::repair::RepairArgs), +} + +/// Check whether `s` looks like a UUID (8-4-4-4-12 hex pattern). +/// +/// Used by [`parse_with_uuid_fallback`] to detect the convenience form +/// `socket-patch ` and rewrite it to `socket-patch get `. +pub fn looks_like_uuid(s: &str) -> bool { + let parts: Vec<&str> = s.split('-').collect(); + if parts.len() != 5 { + return false; + } + let expected = [8, 4, 4, 4, 12]; + parts + .iter() + .zip(expected.iter()) + .all(|(p, &len)| p.len() == len && p.chars().all(|c| c.is_ascii_hexdigit())) +} + +/// Parse a full argv vector, falling back to `get ` when the user +/// invoked `socket-patch [...]` directly. Returns the original clap +/// error if the fallback also fails or if the first arg isn't a UUID. +/// +/// Pulled out of `main.rs` so the fallback path is unit-testable. +pub fn parse_with_uuid_fallback(argv: Vec) -> Result { + match Cli::try_parse_from(&argv) { + Ok(cli) => Ok(cli), + Err(err) => { + if argv.len() >= 2 && looks_like_uuid(&argv[1]) { + let mut new_args = vec![argv[0].clone(), "get".into()]; + new_args.extend_from_slice(&argv[1..]); + match Cli::try_parse_from(&new_args) { + Ok(cli) => Ok(cli), + Err(_) => Err(err), + } + } else { + Err(err) + } + } + } +} diff --git a/crates/socket-patch-cli/src/main.rs b/crates/socket-patch-cli/src/main.rs index e278400..ffdbf6e 100644 --- a/crates/socket-patch-cli/src/main.rs +++ b/crates/socket-patch-cli/src/main.rs @@ -1,82 +1,11 @@ -mod commands; -mod ecosystem_dispatch; -mod output; - -use clap::{Parser, Subcommand}; - -#[derive(Parser)] -#[command( - name = "socket-patch", - about = "CLI tool for applying security patches to dependencies", - version, - propagate_version = true -)] -struct Cli { - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand)] -enum Commands { - /// Apply security patches to dependencies - Apply(commands::apply::ApplyArgs), - - /// Rollback patches to restore original files - Rollback(commands::rollback::RollbackArgs), - - /// Get security patches from Socket API and apply them - #[command(visible_alias = "download")] - Get(commands::get::GetArgs), - - /// Scan installed packages for available security patches - Scan(commands::scan::ScanArgs), - - /// List all patches in the local manifest - List(commands::list::ListArgs), - - /// Remove a patch from the manifest by PURL or UUID (rolls back files first) - Remove(commands::remove::RemoveArgs), - - /// Configure package.json postinstall scripts to apply patches - Setup(commands::setup::SetupArgs), - - /// Download missing blobs and clean up unused blobs - #[command(visible_alias = "gc")] - Repair(commands::repair::RepairArgs), -} - -/// Check whether `s` looks like a UUID (8-4-4-4-12 hex pattern). -fn looks_like_uuid(s: &str) -> bool { - let parts: Vec<&str> = s.split('-').collect(); - if parts.len() != 5 { - return false; - } - let expected = [8, 4, 4, 4, 12]; - parts - .iter() - .zip(expected.iter()) - .all(|(p, &len)| p.len() == len && p.chars().all(|c| c.is_ascii_hexdigit())) -} +use socket_patch_cli::{commands, parse_with_uuid_fallback, Commands}; #[tokio::main] async fn main() { - let cli = match Cli::try_parse() { + let argv: Vec = std::env::args().collect(); + let cli = match parse_with_uuid_fallback(argv) { Ok(cli) => cli, - Err(err) => { - // If parsing failed, check whether the user passed a bare UUID - // (e.g. `socket-patch 80630680-...`) and retry as `get ...`. - let args: Vec = std::env::args().collect(); - if args.len() >= 2 && looks_like_uuid(&args[1]) { - let mut new_args = vec![args[0].clone(), "get".into()]; - new_args.extend_from_slice(&args[1..]); - match Cli::try_parse_from(&new_args) { - Ok(cli) => cli, - Err(_) => err.exit(), - } - } else { - err.exit() - } - } + Err(err) => err.exit(), }; let exit_code = match cli.command { diff --git a/crates/socket-patch-core/src/manifest/operations.rs b/crates/socket-patch-core/src/manifest/operations.rs index 30fae4a..1417775 100644 --- a/crates/socket-patch-core/src/manifest/operations.rs +++ b/crates/socket-patch-core/src/manifest/operations.rs @@ -1,8 +1,19 @@ use std::collections::HashSet; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::manifest::schema::PatchManifest; +/// Resolve a manifest path: absolute paths are returned as-is, relative paths +/// are joined to `cwd`. Centralizes the duplicate block previously inlined in +/// apply/rollback/list/remove/repair commands. +pub fn resolve_manifest_path(cwd: &Path, manifest_path: &str) -> PathBuf { + if Path::new(manifest_path).is_absolute() { + PathBuf::from(manifest_path) + } else { + cwd.join(manifest_path) + } +} + /// Get all blob hashes referenced by a manifest (both beforeHash and afterHash). /// Used for garbage collection and validation. pub fn get_referenced_blobs(manifest: &PatchManifest) -> HashSet { @@ -457,4 +468,30 @@ mod tests { let read_back = read_back.unwrap(); assert_eq!(read_back.patches.len(), 2); } + + #[test] + fn test_resolve_manifest_path_relative_joins_cwd() { + let cwd = Path::new("/tmp/proj"); + let resolved = resolve_manifest_path(cwd, ".socket/manifest.json"); + assert_eq!(resolved, PathBuf::from("/tmp/proj/.socket/manifest.json")); + } + + #[test] + fn test_resolve_manifest_path_absolute_unchanged() { + let cwd = Path::new("/tmp/proj"); + let absolute = if cfg!(windows) { + r"C:\custom\manifest.json" + } else { + "/etc/custom/manifest.json" + }; + let resolved = resolve_manifest_path(cwd, absolute); + assert_eq!(resolved, PathBuf::from(absolute)); + } + + #[test] + fn test_resolve_manifest_path_relative_dotted() { + let cwd = Path::new("/tmp/proj"); + let resolved = resolve_manifest_path(cwd, "../manifest.json"); + assert_eq!(resolved, PathBuf::from("/tmp/proj/../manifest.json")); + } } From c03765d29f63ddb420edeec7f068da9a73739549 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 19 May 2026 16:47:10 -0400 Subject: [PATCH 2/2] test(cli): pin ApplyArgs flag surface + verify_status_str MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lock in the public CLI contract for the `apply` subcommand and add unit tests for its pure JSON-output helpers. No behavior change. `tests/cli_parse_apply.rs` (25 tests) drives `Cli::try_parse_from` to pin every flag in the `apply` row of `CLI_CONTRACT.md`: - Defaults asserted in one block (every field at its documented default value, plus separate dedicated tests for `download_mode` = "diff" and `manifest_path` = ".socket/manifest.json" since these are the values wrappers hard-code). - Both long and short forms exercised for `-d/--dry-run`, `-s/--silent`, `-m/--manifest-path`, `-g/--global`, `-f/--force`, `-v/--verbose`. - Long-only flags (`--cwd`, `--offline`, `--json`, `--global-prefix`) each get a test. - `--ecosystems npm,pypi,cargo` CSV split pinned (contract — a single flag, not repeated). Single-value form also tested. - `--download-mode` accepts `diff`, `package`, `file` (each asserted explicitly so changes to the accepted token set fail here loudly). - Failure path: `--unknown-flag` returns `Err` with `clap::error::ErrorKind::UnknownArgument`. Inline `#[cfg(test)] mod tests` in `apply.rs` (8 tests) covers the two pure helpers used by the JSON code path: - `verify_status_str`: all four `VerifyStatus` variants map to their documented lowercase tags (`ready`, `already_patched`, `hash_mismatch`, `not_found`). - `result_to_json`: top-level key set is exactly {purl, path, success, error, filesPatched, appliedVia, filesVerified}; `filesVerified[0]` keys are exactly {file, status, message, currentHash, expectedHash, targetHash}; a `HashMismatch` entry serializes its `status` to `"hash_mismatch"`; `appliedVia` uses camelCase (NOT snake_case `applied_via`) and serializes as a map of `path -> tag`. Verified: `cargo build --workspace --all-features`, `cargo clippy --workspace --all-features -- -D warnings`, and `cargo test --workspace --all-features --lib --tests` all clean. 33 new tests total (448 in workspace, all passing). Assisted-by: Claude Code:claude-opus-4-7 --- crates/socket-patch-cli/src/commands/apply.rs | 181 +++++++++++++++ .../socket-patch-cli/tests/cli_parse_apply.rs | 216 ++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 crates/socket-patch-cli/tests/cli_parse_apply.rs diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 0f88fe1..2a690f4 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -618,3 +618,184 @@ async fn apply_patches_inner( Ok((!has_errors, results, unmatched)) } + +#[cfg(test)] +mod tests { + //! Pure-helper tests for the `apply` subcommand. These pin the JSON + //! key shape produced by `result_to_json` and the lowercase string + //! tags emitted by `verify_status_str` — both part of the public + //! contract documented in `CLI_CONTRACT.md`. + use super::*; + use socket_patch_core::patch::apply::{ + ApplyResult, AppliedVia, VerifyResult, VerifyStatus, + }; + + // ----------------------------------------------------------------- + // verify_status_str — every VerifyStatus variant must map to the + // exact lowercase tag documented in the JSON contract. + // ----------------------------------------------------------------- + + #[test] + fn verify_status_str_ready() { + assert_eq!(verify_status_str(&VerifyStatus::Ready), "ready"); + } + + #[test] + fn verify_status_str_already_patched() { + assert_eq!( + verify_status_str(&VerifyStatus::AlreadyPatched), + "already_patched" + ); + } + + #[test] + fn verify_status_str_hash_mismatch() { + assert_eq!( + verify_status_str(&VerifyStatus::HashMismatch), + "hash_mismatch" + ); + } + + #[test] + fn verify_status_str_not_found() { + assert_eq!(verify_status_str(&VerifyStatus::NotFound), "not_found"); + } + + // ----------------------------------------------------------------- + // result_to_json — top-level keys and filesVerified[0] keys are part + // of the JSON output contract. Wrappers and CI scripts read these. + // ----------------------------------------------------------------- + + /// Build an `ApplyResult` with a single fully-populated VerifyResult + /// so we can exercise every JSON key in one shot. + fn sample_result_with_verify(status: VerifyStatus) -> ApplyResult { + ApplyResult { + package_key: "pkg:npm/minimist@1.2.2".to_string(), + package_path: "/tmp/node_modules/minimist".to_string(), + success: true, + files_verified: vec![VerifyResult { + file: "package/index.js".to_string(), + status, + message: Some("ok".to_string()), + current_hash: Some("aaa".to_string()), + expected_hash: Some("bbb".to_string()), + target_hash: Some("ccc".to_string()), + }], + files_patched: vec!["package/index.js".to_string()], + applied_via: HashMap::new(), + error: None, + } + } + + #[test] + fn result_to_json_top_level_keys() { + let result = sample_result_with_verify(VerifyStatus::Ready); + let v = result_to_json(&result); + let obj = v.as_object().expect("top-level must be a JSON object"); + + // The exact set of top-level keys is contract; any addition or + // rename here is a breaking change for downstream wrappers. + let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect(); + keys.sort(); + assert_eq!( + keys, + vec![ + "appliedVia", + "error", + "filesPatched", + "filesVerified", + "path", + "purl", + "success", + ] + ); + + // Spot-check value mapping for the simple scalar fields. + assert_eq!(v["purl"], "pkg:npm/minimist@1.2.2"); + assert_eq!(v["path"], "/tmp/node_modules/minimist"); + assert_eq!(v["success"], true); + assert_eq!(v["error"], serde_json::Value::Null); + assert_eq!(v["filesPatched"][0], "package/index.js"); + } + + #[test] + fn result_to_json_files_verified_entry_keys() { + let result = sample_result_with_verify(VerifyStatus::Ready); + let v = result_to_json(&result); + let entry = v["filesVerified"][0] + .as_object() + .expect("filesVerified[0] must be a JSON object"); + + let mut keys: Vec<&str> = entry.keys().map(String::as_str).collect(); + keys.sort(); + assert_eq!( + keys, + vec![ + "currentHash", + "expectedHash", + "file", + "message", + "status", + "targetHash", + ] + ); + + assert_eq!(v["filesVerified"][0]["file"], "package/index.js"); + assert_eq!(v["filesVerified"][0]["status"], "ready"); + assert_eq!(v["filesVerified"][0]["message"], "ok"); + assert_eq!(v["filesVerified"][0]["currentHash"], "aaa"); + assert_eq!(v["filesVerified"][0]["expectedHash"], "bbb"); + assert_eq!(v["filesVerified"][0]["targetHash"], "ccc"); + } + + #[test] + fn result_to_json_hash_mismatch_status_tag() { + // The `hash_mismatch` snake_case tag is the contract value. + // `verify_status_str` produces it; verify it survives the round + // trip through `result_to_json`. + let result = sample_result_with_verify(VerifyStatus::HashMismatch); + let v = result_to_json(&result); + assert_eq!(v["filesVerified"][0]["status"], "hash_mismatch"); + } + + #[test] + fn result_to_json_applied_via_uses_camel_case_key() { + // `appliedVia` must be camelCase in JSON output, not snake_case + // `applied_via`. This is divergent from the Rust struct field + // name and is part of the contract — wrappers parse this key. + let mut applied_via = HashMap::new(); + applied_via.insert("package/index.js".to_string(), AppliedVia::Diff); + applied_via.insert("package/lib/foo.js".to_string(), AppliedVia::Package); + + let result = ApplyResult { + package_key: "pkg:npm/minimist@1.2.2".to_string(), + package_path: "/tmp/node_modules/minimist".to_string(), + success: true, + files_verified: Vec::new(), + files_patched: vec![ + "package/index.js".to_string(), + "package/lib/foo.js".to_string(), + ], + applied_via, + error: None, + }; + let v = result_to_json(&result); + + // Key must be `appliedVia`, not `applied_via`. + assert!(v.get("appliedVia").is_some()); + assert!(v.get("applied_via").is_none()); + + // Value must serialize as a JSON object map (not array). + let map = v["appliedVia"] + .as_object() + .expect("appliedVia must serialize as a JSON object"); + assert_eq!(map.len(), 2); + // The lowercase tags from `AppliedVia::as_tag` are themselves + // contract values (`diff`, `package`, `blob`). + assert_eq!(map.get("package/index.js").and_then(|v| v.as_str()), Some("diff")); + assert_eq!( + map.get("package/lib/foo.js").and_then(|v| v.as_str()), + Some("package"), + ); + } +} diff --git a/crates/socket-patch-cli/tests/cli_parse_apply.rs b/crates/socket-patch-cli/tests/cli_parse_apply.rs new file mode 100644 index 0000000..096a855 --- /dev/null +++ b/crates/socket-patch-cli/tests/cli_parse_apply.rs @@ -0,0 +1,216 @@ +//! Parser snapshot tests for the `apply` subcommand. +//! +//! These tests pin **every flag name, short form, and default value** +//! listed in `crates/socket-patch-cli/CLI_CONTRACT.md` for `apply`. A +//! rename, dropped short form, or default-value drift fails here loudly +//! instead of silently breaking the npm/pypi/cargo wrappers and CI +//! scripts that depend on the surface. + +use std::path::PathBuf; + +use clap::Parser; +use socket_patch_cli::commands::apply::ApplyArgs; +use socket_patch_cli::{Cli, Commands}; + +/// Parse `socket-patch apply ` and return the inner `ApplyArgs`. +/// Panics if parsing fails or yields a non-`Apply` subcommand — tests for +/// the failure path call `Cli::try_parse_from` directly. +fn parse_apply(extra: &[&str]) -> ApplyArgs { + let mut argv: Vec<&str> = vec!["socket-patch", "apply"]; + argv.extend_from_slice(extra); + let cli = Cli::try_parse_from(&argv).expect("parse"); + match cli.command { + Commands::Apply(a) => a, + _ => panic!("expected Apply"), + } +} + +// --------------------------------------------------------------------------- +// Defaults — every default value from the contract table is pinned here. +// --------------------------------------------------------------------------- + +#[test] +fn defaults_match_contract() { + let a = parse_apply(&[]); + assert_eq!(a.cwd, PathBuf::from(".")); + assert!(!a.dry_run); + assert!(!a.silent); + assert_eq!(a.manifest_path, ".socket/manifest.json"); + assert!(!a.offline); + assert!(!a.global); + assert_eq!(a.global_prefix, None); + assert_eq!(a.ecosystems, None); + assert!(!a.force); + assert!(!a.json); + assert!(!a.verbose); + assert_eq!(a.download_mode, "diff"); +} + +/// The `download_mode` default is pinned separately — it's the one +/// field whose default value diverges across subcommands historically, +/// so we assert it explicitly to catch drift. +#[test] +fn default_download_mode_is_diff() { + assert_eq!(parse_apply(&[]).download_mode, "diff"); +} + +/// The `manifest_path` default is contract — many scripts hard-code +/// `.socket/manifest.json` as the canonical location. +#[test] +fn default_manifest_path_is_dot_socket_manifest_json() { + assert_eq!(parse_apply(&[]).manifest_path, ".socket/manifest.json"); +} + +// --------------------------------------------------------------------------- +// Boolean flags — long form, then short form (where applicable). +// --------------------------------------------------------------------------- + +#[test] +fn dry_run_long() { + assert!(parse_apply(&["--dry-run"]).dry_run); +} + +#[test] +fn dry_run_short() { + assert!(parse_apply(&["-d"]).dry_run); +} + +#[test] +fn silent_long() { + assert!(parse_apply(&["--silent"]).silent); +} + +#[test] +fn silent_short() { + assert!(parse_apply(&["-s"]).silent); +} + +#[test] +fn global_long() { + assert!(parse_apply(&["--global"]).global); +} + +#[test] +fn global_short() { + assert!(parse_apply(&["-g"]).global); +} + +#[test] +fn force_long() { + assert!(parse_apply(&["--force"]).force); +} + +#[test] +fn force_short() { + assert!(parse_apply(&["-f"]).force); +} + +#[test] +fn verbose_long() { + assert!(parse_apply(&["--verbose"]).verbose); +} + +#[test] +fn verbose_short() { + assert!(parse_apply(&["-v"]).verbose); +} + +#[test] +fn offline_long() { + assert!(parse_apply(&["--offline"]).offline); +} + +#[test] +fn json_long() { + assert!(parse_apply(&["--json"]).json); +} + +// --------------------------------------------------------------------------- +// Value flags — long form, then short form (where applicable). +// --------------------------------------------------------------------------- + +#[test] +fn cwd_long() { + assert_eq!(parse_apply(&["--cwd", "/tmp/x"]).cwd, PathBuf::from("/tmp/x")); +} + +#[test] +fn manifest_path_long() { + assert_eq!( + parse_apply(&["--manifest-path", "custom.json"]).manifest_path, + "custom.json" + ); +} + +#[test] +fn manifest_path_short() { + assert_eq!(parse_apply(&["-m", "custom.json"]).manifest_path, "custom.json"); +} + +#[test] +fn global_prefix_long() { + assert_eq!( + parse_apply(&["--global-prefix", "/foo"]).global_prefix, + Some(PathBuf::from("/foo")) + ); +} + +// --------------------------------------------------------------------------- +// --ecosystems CSV split — the contract is that a comma-delimited value +// expands into a Vec. Wrappers rely on this single-flag form. +// --------------------------------------------------------------------------- + +#[test] +fn ecosystems_csv_splits_into_vec() { + assert_eq!( + parse_apply(&["--ecosystems", "npm,pypi,cargo"]).ecosystems, + Some(vec!["npm".to_string(), "pypi".to_string(), "cargo".to_string()]) + ); +} + +#[test] +fn ecosystems_single_value() { + assert_eq!( + parse_apply(&["--ecosystems", "npm"]).ecosystems, + Some(vec!["npm".to_string()]) + ); +} + +// --------------------------------------------------------------------------- +// --download-mode — accepted token values are documented contract. +// --------------------------------------------------------------------------- + +#[test] +fn download_mode_diff() { + assert_eq!(parse_apply(&["--download-mode", "diff"]).download_mode, "diff"); +} + +#[test] +fn download_mode_package() { + assert_eq!( + parse_apply(&["--download-mode", "package"]).download_mode, + "package" + ); +} + +#[test] +fn download_mode_file() { + assert_eq!(parse_apply(&["--download-mode", "file"]).download_mode, "file"); +} + +// --------------------------------------------------------------------------- +// Failure path — unknown flags must produce a clap UnknownArgument error. +// This guards against accidentally accepting a typo via positional fallback. +// --------------------------------------------------------------------------- + +#[test] +fn unknown_flag_fails_with_unknown_argument() { + // `Cli` doesn't implement `Debug`, so we can't use `.expect_err()` — + // match the Result by hand. + match Cli::try_parse_from(["socket-patch", "apply", "--unknown-flag"]) { + Ok(_) => panic!("--unknown-flag must be rejected"), + Err(err) => { + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); + } + } +}