Skip to content

Commit ab0b1ac

Browse files
committed
test(cli): unit-test looks_like_uuid + parse_with_uuid_fallback
Add inline #[cfg(test)] coverage for the bare-UUID fallback in src/lib.rs — case-insensitive shape predicate, the get-rewrite shortcut, no-double-rewrite invariant, and original-error preservation when the rewrite parse also fails. Add a new tests/cli_parse_main.rs integration suite that locks down the top-level Cli parser: --help / --version / no-subcommand / unknown-subcommand error kinds, every subcommand name, and the download/gc visible aliases. Assisted-by: Claude Code:claude-opus-4-7
1 parent 6cb84b8 commit ab0b1ac

2 files changed

Lines changed: 297 additions & 0 deletions

File tree

crates/socket-patch-cli/src/lib.rs

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,162 @@ pub fn parse_with_uuid_fallback(argv: Vec<String>) -> Result<Cli, clap::Error> {
9494
}
9595
}
9696
}
97+
98+
#[cfg(test)]
99+
mod tests {
100+
//! Unit tests for the bare-UUID fallback. These tests lock in the
101+
//! `socket-patch <UUID>` rewrite shortcut and the shape predicate it
102+
//! uses — both of which are part of the CLI contract (see
103+
//! `CLI_CONTRACT.md`).
104+
use super::*;
105+
106+
// ---------- looks_like_uuid ----------
107+
108+
#[test]
109+
fn looks_like_uuid_accepts_canonical_lowercase() {
110+
assert!(looks_like_uuid("80630680-4da6-45f9-bba8-b888e0ffd58c"));
111+
}
112+
113+
#[test]
114+
fn looks_like_uuid_accepts_uppercase() {
115+
// `is_ascii_hexdigit` accepts A-F as well as a-f, so all-uppercase
116+
// UUIDs must still pass the shape check.
117+
assert!(looks_like_uuid("80630680-4DA6-45F9-BBA8-B888E0FFD58C"));
118+
}
119+
120+
#[test]
121+
fn looks_like_uuid_accepts_mixed_case() {
122+
assert!(looks_like_uuid("80630680-4Da6-45F9-bBa8-B888e0FfD58c"));
123+
}
124+
125+
#[test]
126+
fn looks_like_uuid_rejects_four_groups() {
127+
// 8-4-4-4 — missing the final 12-char group.
128+
assert!(!looks_like_uuid("80630680-4da6-45f9-bba8"));
129+
}
130+
131+
#[test]
132+
fn looks_like_uuid_rejects_six_groups() {
133+
// One too many groups — the split count must be exactly 5.
134+
assert!(!looks_like_uuid(
135+
"80630680-4da6-45f9-bba8-b888e0ffd58c-extra"
136+
));
137+
}
138+
139+
#[test]
140+
fn looks_like_uuid_rejects_8_4_4_4_13_group_lengths() {
141+
// Final group has 13 chars instead of 12.
142+
assert!(!looks_like_uuid("80630680-4da6-45f9-bba8-b888e0ffd58cc"));
143+
}
144+
145+
#[test]
146+
fn looks_like_uuid_rejects_7_4_4_4_12_group_lengths() {
147+
// First group has 7 chars instead of 8.
148+
assert!(!looks_like_uuid("8063068-4da6-45f9-bba8-b888e0ffd58c0"));
149+
}
150+
151+
#[test]
152+
fn looks_like_uuid_rejects_non_hex_chars() {
153+
// `g` is not a hex digit — must fail even though the shape is right.
154+
assert!(!looks_like_uuid("g0630680-4da6-45f9-bba8-b888e0ffd58c"));
155+
assert!(!looks_like_uuid("80630680-4dz6-45f9-bba8-b888e0ffd58c"));
156+
assert!(!looks_like_uuid("80630680-4da6-45f9-bba8-b888e0ffd58z"));
157+
}
158+
159+
#[test]
160+
fn looks_like_uuid_rejects_empty_string() {
161+
assert!(!looks_like_uuid(""));
162+
}
163+
164+
#[test]
165+
fn looks_like_uuid_rejects_string_with_no_dashes() {
166+
// 32 hex chars, no dashes — close to a UUID but not the right shape.
167+
assert!(!looks_like_uuid("806306804da645f9bba8b888e0ffd58c"));
168+
}
169+
170+
#[test]
171+
fn looks_like_uuid_rejects_bare_dashes() {
172+
// Five empty groups — split count is right, group lengths aren't.
173+
assert!(!looks_like_uuid("----"));
174+
}
175+
176+
// ---------- parse_with_uuid_fallback ----------
177+
178+
const UUID: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c";
179+
180+
fn argv(items: &[&str]) -> Vec<String> {
181+
items.iter().map(|s| (*s).to_string()).collect()
182+
}
183+
184+
#[test]
185+
fn fallback_rewrites_bare_uuid_to_get() {
186+
let cli = parse_with_uuid_fallback(argv(&["socket-patch", UUID])).unwrap();
187+
match cli.command {
188+
Commands::Get(args) => assert_eq!(args.identifier, UUID),
189+
_ => panic!("expected Commands::Get"),
190+
}
191+
}
192+
193+
#[test]
194+
fn fallback_preserves_trailing_flags() {
195+
// Flags after the UUID must be forwarded to the synthesized `get`.
196+
let cli = parse_with_uuid_fallback(argv(&["socket-patch", UUID, "--json"])).unwrap();
197+
match cli.command {
198+
Commands::Get(args) => {
199+
assert_eq!(args.identifier, UUID);
200+
assert!(args.json, "--json should be forwarded to get");
201+
}
202+
_ => panic!("expected Commands::Get"),
203+
}
204+
}
205+
206+
#[test]
207+
fn fallback_returns_original_error_when_first_arg_is_not_uuid() {
208+
// No rewrite should happen; the original clap error must surface.
209+
// `Cli` doesn't derive `Debug`, so `unwrap_err()` doesn't compile —
210+
// pull the error out via `match` instead.
211+
let err = match parse_with_uuid_fallback(argv(&["socket-patch", "not-a-uuid"])) {
212+
Ok(_) => panic!("expected parse to fail"),
213+
Err(e) => e,
214+
};
215+
assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
216+
}
217+
218+
#[test]
219+
fn fallback_is_skipped_when_normal_parse_succeeds() {
220+
// `list` parses normally — fallback should not engage.
221+
let cli = parse_with_uuid_fallback(argv(&["socket-patch", "list"])).unwrap();
222+
assert!(matches!(cli.command, Commands::List(_)));
223+
}
224+
225+
#[test]
226+
fn fallback_does_not_double_rewrite_explicit_get() {
227+
// `socket-patch get <UUID>` already parses; fallback never runs.
228+
let cli = parse_with_uuid_fallback(argv(&["socket-patch", "get", UUID])).unwrap();
229+
match cli.command {
230+
Commands::Get(args) => assert_eq!(args.identifier, UUID),
231+
_ => panic!("expected Commands::Get"),
232+
}
233+
}
234+
235+
#[test]
236+
fn fallback_surfaces_original_error_when_rewrite_also_fails() {
237+
// UUID is valid-shaped so a rewrite is attempted, but `get` doesn't
238+
// accept this flag — the rewrite parse fails and we must return the
239+
// ORIGINAL error (the one from the un-rewritten parse), not the
240+
// rewrite's error.
241+
let err = match parse_with_uuid_fallback(argv(&[
242+
"socket-patch",
243+
UUID,
244+
"--invalid-flag-that-get-does-not-accept",
245+
])) {
246+
Ok(_) => panic!("expected parse to fail"),
247+
Err(e) => e,
248+
};
249+
// The original parse failed because `<UUID>` isn't a known
250+
// subcommand, so the surfaced error must be InvalidSubcommand —
251+
// NOT UnknownArgument (which is what the rewrite parse would have
252+
// produced).
253+
assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
254+
}
255+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
//! Top-level `Cli::try_parse_from` behavior tests.
2+
//!
3+
//! These tests cover the parser surface that doesn't fit in
4+
//! `src/lib.rs::tests` — clap's auto-generated help/version handling, the
5+
//! "no subcommand" error kind, every subcommand name, and the
6+
//! visible_alias values (`download` for `get`, `gc` for `repair`).
7+
//!
8+
//! Each subcommand name and alias here is part of the CLI contract
9+
//! defined in `crates/socket-patch-cli/CLI_CONTRACT.md`.
10+
11+
use clap::Parser;
12+
use socket_patch_cli::{Cli, Commands};
13+
14+
fn parse(argv: &[&str]) -> Result<Cli, clap::Error> {
15+
Cli::try_parse_from(argv)
16+
}
17+
18+
/// Pull the error out of a parse result. `Cli` doesn't derive `Debug`,
19+
/// so `Result::unwrap_err` won't compile — this helper sidesteps that.
20+
fn expect_err(result: Result<Cli, clap::Error>) -> clap::Error {
21+
match result {
22+
Ok(_) => panic!("expected parse to fail"),
23+
Err(e) => e,
24+
}
25+
}
26+
27+
// ---------- top-level error kinds ----------
28+
29+
#[test]
30+
fn no_subcommand_returns_display_help_on_missing() {
31+
// clap v4 returns `DisplayHelpOnMissingArgumentOrSubcommand` (not
32+
// `MissingSubcommand`) for `socket-patch` with no args when a
33+
// subcommand is required — this is the kind the binary's main.rs
34+
// handler branches on.
35+
let err = expect_err(parse(&["socket-patch"]));
36+
assert_eq!(
37+
err.kind(),
38+
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
39+
);
40+
}
41+
42+
#[test]
43+
fn version_flag_triggers_display_version() {
44+
let err = expect_err(parse(&["socket-patch", "--version"]));
45+
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
46+
}
47+
48+
#[test]
49+
fn help_flag_triggers_display_help() {
50+
let err = expect_err(parse(&["socket-patch", "--help"]));
51+
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
52+
}
53+
54+
#[test]
55+
fn unknown_subcommand_returns_invalid_subcommand() {
56+
let err = expect_err(parse(&["socket-patch", "bogus"]));
57+
assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
58+
}
59+
60+
// ---------- every subcommand name parses ----------
61+
62+
#[test]
63+
fn apply_subcommand_parses() {
64+
let cli = parse(&["socket-patch", "apply"]).expect("apply must parse with no positional");
65+
assert!(matches!(cli.command, Commands::Apply(_)));
66+
}
67+
68+
#[test]
69+
fn rollback_subcommand_parses_without_identifier() {
70+
// rollback's identifier is optional — bare `rollback` must succeed.
71+
let cli =
72+
parse(&["socket-patch", "rollback"]).expect("rollback must parse with no positional");
73+
assert!(matches!(cli.command, Commands::Rollback(_)));
74+
}
75+
76+
#[test]
77+
fn get_subcommand_parses_with_identifier() {
78+
let cli = parse(&["socket-patch", "get", "some-id"]).expect("get must parse with identifier");
79+
match cli.command {
80+
Commands::Get(args) => assert_eq!(args.identifier, "some-id"),
81+
_ => panic!("expected Commands::Get"),
82+
}
83+
}
84+
85+
#[test]
86+
fn scan_subcommand_parses() {
87+
let cli = parse(&["socket-patch", "scan"]).expect("scan must parse with no positional");
88+
assert!(matches!(cli.command, Commands::Scan(_)));
89+
}
90+
91+
#[test]
92+
fn list_subcommand_parses() {
93+
let cli = parse(&["socket-patch", "list"]).expect("list must parse with no positional");
94+
assert!(matches!(cli.command, Commands::List(_)));
95+
}
96+
97+
#[test]
98+
fn remove_subcommand_parses_with_identifier() {
99+
let cli =
100+
parse(&["socket-patch", "remove", "some-id"]).expect("remove must parse with identifier");
101+
match cli.command {
102+
Commands::Remove(args) => assert_eq!(args.identifier, "some-id"),
103+
_ => panic!("expected Commands::Remove"),
104+
}
105+
}
106+
107+
#[test]
108+
fn setup_subcommand_parses() {
109+
let cli = parse(&["socket-patch", "setup"]).expect("setup must parse with no positional");
110+
assert!(matches!(cli.command, Commands::Setup(_)));
111+
}
112+
113+
#[test]
114+
fn repair_subcommand_parses() {
115+
let cli = parse(&["socket-patch", "repair"]).expect("repair must parse with no positional");
116+
assert!(matches!(cli.command, Commands::Repair(_)));
117+
}
118+
119+
// ---------- visible aliases ----------
120+
121+
#[test]
122+
fn download_alias_parses_as_get() {
123+
// `download` is the visible_alias for `get` — wrappers in the wild
124+
// call this name directly, so it has to keep working.
125+
let cli = parse(&["socket-patch", "download", "some-id"])
126+
.expect("`download` alias must parse as Get");
127+
match cli.command {
128+
Commands::Get(args) => assert_eq!(args.identifier, "some-id"),
129+
_ => panic!("expected Commands::Get via `download` alias"),
130+
}
131+
}
132+
133+
#[test]
134+
fn gc_alias_parses_as_repair() {
135+
// `gc` is the visible_alias for `repair`.
136+
let cli = parse(&["socket-patch", "gc"]).expect("`gc` alias must parse as Repair");
137+
assert!(matches!(cli.command, Commands::Repair(_)));
138+
}

0 commit comments

Comments
 (0)