Skip to content

Commit f948e3f

Browse files
committed
refactor(arg-parsing): extract shared parseSlashSeparatedArg helper
The slash-splitting logic was duplicated across event/view.ts, trace/view.ts, and log/view.ts. Centralise it in src/lib/arg-parsing.ts so future bug fixes only need to be made in one place.
1 parent 6e2061d commit f948e3f

File tree

4 files changed

+75
-75
lines changed

4 files changed

+75
-75
lines changed

src/commands/event/view.ts

Lines changed: 6 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { getEvent } from "../../lib/api-client.js";
99
import {
1010
ProjectSpecificationType,
1111
parseOrgProjectArg,
12+
parseSlashSeparatedArg,
1213
spansFlag,
1314
} from "../../lib/arg-parsing.js";
1415
import { openInBrowser } from "../../lib/browser.js";
@@ -113,30 +114,11 @@ export function parsePositionalArgs(args: string[]): {
113114
}
114115

115116
if (args.length === 1) {
116-
const slashIdx = first.indexOf("/");
117-
118-
if (slashIdx === -1) {
119-
// No slashes — plain event ID
120-
return { eventId: first, targetArg: undefined };
121-
}
122-
123-
// Event IDs are hex and never contain "/" — this must be a structured
124-
// "org/project/eventId" or "org/project" (missing event ID)
125-
const lastSlashIdx = first.lastIndexOf("/");
126-
127-
if (slashIdx === lastSlashIdx) {
128-
// Exactly one slash: "org/project" without event ID
129-
throw new ContextError("Event ID", USAGE_HINT);
130-
}
131-
132-
// Two+ slashes: split on last "/" → target + eventId
133-
const targetArg = first.slice(0, lastSlashIdx);
134-
const eventId = first.slice(lastSlashIdx + 1);
135-
136-
if (!eventId) {
137-
throw new ContextError("Event ID", USAGE_HINT);
138-
}
139-
117+
const { id: eventId, targetArg } = parseSlashSeparatedArg(
118+
first,
119+
"Event ID",
120+
USAGE_HINT
121+
);
140122
return { eventId, targetArg };
141123
}
142124

src/commands/log/view.ts

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
import { buildCommand } from "@stricli/core";
88
import type { SentryContext } from "../../context.js";
99
import { getLog } from "../../lib/api-client.js";
10-
import { parseOrgProjectArg } from "../../lib/arg-parsing.js";
10+
import {
11+
parseOrgProjectArg,
12+
parseSlashSeparatedArg,
13+
} from "../../lib/arg-parsing.js";
1114
import { openInBrowser } from "../../lib/browser.js";
1215
import { ContextError, ValidationError } from "../../lib/errors.js";
1316
import { formatLogDetails, writeJson } from "../../lib/formatters/index.js";
@@ -48,30 +51,11 @@ export function parsePositionalArgs(args: string[]): {
4851
}
4952

5053
if (args.length === 1) {
51-
const slashIdx = first.indexOf("/");
52-
53-
if (slashIdx === -1) {
54-
// No slashes — plain log ID
55-
return { logId: first, targetArg: undefined };
56-
}
57-
58-
// Log IDs are hex and never contain "/" — this must be a structured
59-
// "org/project/logId" or "org/project" (missing log ID)
60-
const lastSlashIdx = first.lastIndexOf("/");
61-
62-
if (slashIdx === lastSlashIdx) {
63-
// Exactly one slash: "org/project" without log ID
64-
throw new ContextError("Log ID", USAGE_HINT);
65-
}
66-
67-
// Two+ slashes: split on last "/" → target + logId
68-
const targetArg = first.slice(0, lastSlashIdx);
69-
const logId = first.slice(lastSlashIdx + 1);
70-
71-
if (!logId) {
72-
throw new ContextError("Log ID", USAGE_HINT);
73-
}
74-
54+
const { id: logId, targetArg } = parseSlashSeparatedArg(
55+
first,
56+
"Log ID",
57+
USAGE_HINT
58+
);
7559
return { logId, targetArg };
7660
}
7761

src/commands/trace/view.ts

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
import { buildCommand } from "@stricli/core";
88
import type { SentryContext } from "../../context.js";
99
import { getDetailedTrace } from "../../lib/api-client.js";
10-
import { parseOrgProjectArg, spansFlag } from "../../lib/arg-parsing.js";
10+
import {
11+
parseOrgProjectArg,
12+
parseSlashSeparatedArg,
13+
spansFlag,
14+
} from "../../lib/arg-parsing.js";
1115
import { openInBrowser } from "../../lib/browser.js";
1216
import { ContextError, ValidationError } from "../../lib/errors.js";
1317
import {
@@ -55,30 +59,11 @@ export function parsePositionalArgs(args: string[]): {
5559
}
5660

5761
if (args.length === 1) {
58-
const slashIdx = first.indexOf("/");
59-
60-
if (slashIdx === -1) {
61-
// No slashes — plain trace ID
62-
return { traceId: first, targetArg: undefined };
63-
}
64-
65-
// Trace IDs are hex and never contain "/" — this must be a structured
66-
// "org/project/traceId" or "org/project" (missing trace ID)
67-
const lastSlashIdx = first.lastIndexOf("/");
68-
69-
if (slashIdx === lastSlashIdx) {
70-
// Exactly one slash: "org/project" without trace ID
71-
throw new ContextError("Trace ID", USAGE_HINT);
72-
}
73-
74-
// Two+ slashes: split on last "/" → target + traceId
75-
const targetArg = first.slice(0, lastSlashIdx);
76-
const traceId = first.slice(lastSlashIdx + 1);
77-
78-
if (!traceId) {
79-
throw new ContextError("Trace ID", USAGE_HINT);
80-
}
81-
62+
const { id: traceId, targetArg } = parseSlashSeparatedArg(
63+
first,
64+
"Trace ID",
65+
USAGE_HINT
66+
);
8267
return { traceId, targetArg };
8368
}
8469

src/lib/arg-parsing.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* project list) and single-item commands (issue view, explain, plan).
77
*/
88

9-
import { ValidationError } from "./errors.js";
9+
import { ContextError, ValidationError } from "./errors.js";
1010
import type { ParsedSentryUrl } from "./sentry-url-parser.js";
1111
import { applySentryUrlContext, parseSentryUrl } from "./sentry-url-parser.js";
1212
import { isAllDigits } from "./utils.js";
@@ -345,6 +345,55 @@ function parseWithDash(arg: string): ParsedIssueArg {
345345
return { type: "project-search", projectSlug, suffix };
346346
}
347347

348+
/**
349+
* Parse a single positional arg that may be a plain hex ID or a slash-separated
350+
* `org/project/id` pattern.
351+
*
352+
* Used by commands whose IDs are hex strings that never contain `/`
353+
* (event, trace, log), making the pattern unambiguous:
354+
* - No slashes → plain ID, no target
355+
* - Exactly one slash → `org/project` without ID → throws {@link ContextError}
356+
* - Two or more slashes → splits on last `/` → `targetArg` + `id`
357+
*
358+
* @param arg - The raw single positional argument
359+
* @param idLabel - Human-readable ID label for error messages (e.g. `"Event ID"`)
360+
* @param usageHint - Usage example shown in error messages
361+
* @returns Parsed `{ id, targetArg }` — `targetArg` is `undefined` for plain IDs
362+
* @throws {ContextError} When the arg contains exactly one slash (missing ID)
363+
* or ends with a trailing slash (empty ID segment)
364+
*/
365+
export function parseSlashSeparatedArg(
366+
arg: string,
367+
idLabel: string,
368+
usageHint: string
369+
): { id: string; targetArg: string | undefined } {
370+
const slashIdx = arg.indexOf("/");
371+
372+
if (slashIdx === -1) {
373+
// No slashes — plain ID
374+
return { id: arg, targetArg: undefined };
375+
}
376+
377+
// IDs are hex and never contain "/" — this must be a structured
378+
// "org/project/id" or "org/project" (missing ID)
379+
const lastSlashIdx = arg.lastIndexOf("/");
380+
381+
if (slashIdx === lastSlashIdx) {
382+
// Exactly one slash: "org/project" without ID
383+
throw new ContextError(idLabel, usageHint);
384+
}
385+
386+
// Two+ slashes: split on last "/" → target + id
387+
const targetArg = arg.slice(0, lastSlashIdx);
388+
const id = arg.slice(lastSlashIdx + 1);
389+
390+
if (!id) {
391+
throw new ContextError(idLabel, usageHint);
392+
}
393+
394+
return { id, targetArg };
395+
}
396+
348397
export function parseIssueArg(arg: string): ParsedIssueArg {
349398
// 0. URL detection — extract issue ID from Sentry web URLs
350399
const urlParsed = parseSentryUrl(arg);

0 commit comments

Comments
 (0)