Skip to main content

logbrew_cli/
parser.rs

1//! CLI command grammar.
2
3mod help_topics;
4mod issue_shortcuts;
5mod log_shortcuts;
6mod support;
7mod trace_reads;
8mod watch;
9
10use help_topics::{
11    command_shaped_help_topic, contains_help_flag, ensure_no_help_positionals, help_command,
12    help_topic, is_direct_filter_help_alias, is_help_flag, parse_help, parse_help_alias,
13    parse_literal_help, positional_args, validate_help_flags,
14};
15use issue_shortcuts::{
16    has_issue_status_action, is_issue_status_action_alias, parse_bare_issue_status_shortcut,
17    parse_issue_first_status_shortcut, parse_issue_status_shortcut,
18    parse_status_first_issue_id_shortcut,
19};
20use log_shortcuts::{literal_log_search_separator_index, log_shortcut_args};
21use support::parse_support;
22use trace_reads::{parse_trace_detail_or_explain, parse_trace_list_read};
23use watch::parse_watch;
24
25use crate::flags::{
26    FlagScope, is_read_filter_word, is_simple_flag, normalize_log_level, normalize_status,
27    parse_flags, validate_min_duration,
28};
29use crate::ids::{infer_explain_target, is_issue_id, is_pasted_detail_id, is_trace_id, is_uuid};
30use crate::{
31    CliError, Command, ExplainTarget, HelpTopic, ISSUE_STATUS_ARGUMENT_NEXT_STEP,
32    ProjectCreateOptions, ProjectIngestKeyCreateOptions, ProjectSetupSeenOptions, ReadOptions,
33    ReadTarget, SetTarget, auth_namespace,
34};
35
36/// Standard next step for malformed help invocations.
37const HELP_NEXT_STEP: &str = "run logbrew --help";
38/// Valid resources for historical reads.
39const READ_RESOURCE_NEXT_STEP: &str =
40    "choose one of logs, issues, actions, releases, traces, trace, issue";
41/// Recovery hint for users who type plural trace resources.
42const READ_TRACE_ALIAS_NEXT_STEP: &str =
43    "use singular trace with an id: logbrew read trace <trace_id>";
44/// Recovery hint for users who type trace terminology as a top-level command.
45const TRACE_COMMAND_NEXT_STEP: &str =
46    "use logbrew trace <trace_id> or logbrew explain trace <trace_id>";
47/// Help for trace detail reads.
48const READ_TRACE_NEXT_STEP: &str = "run logbrew read trace --help";
49/// Help for issue detail reads.
50const READ_ISSUE_NEXT_STEP: &str = "run logbrew read issue --help";
51/// Help for log list reads.
52const READ_LOGS_NEXT_STEP: &str = "run logbrew read logs --help";
53/// Recovery hint for natural log search shortcuts.
54const SEARCH_NEXT_STEP: &str = "provide search text or run logbrew logs --help";
55/// Help for issue list reads.
56const READ_ISSUES_NEXT_STEP: &str = "run logbrew read issues --help";
57/// Help for action list reads.
58const READ_ACTIONS_NEXT_STEP: &str = "run logbrew read actions --help";
59/// Help for release list reads.
60const READ_RELEASES_NEXT_STEP: &str = "run logbrew read releases --help";
61/// Help for recent trace discovery.
62const READ_TRACES_NEXT_STEP: &str = "run logbrew read traces --help";
63/// Help for backend-owned project setup discovery.
64const PROJECTS_NEXT_STEP: &str = "run logbrew projects --help";
65/// Help for backend-owned project setup seen calls.
66const PROJECT_SETUP_SEEN_NEXT_STEP: &str = "run logbrew projects setup <project_id> --help";
67/// Valid setup source values for setup seen calls.
68const PROJECT_SETUP_SOURCE_NEXT_STEP: &str = "use --source api, cli, or sdk";
69/// Valid resources for live watch.
70const WATCH_RESOURCE_NEXT_STEP: &str = "choose logs, issues, actions, or omit a resource";
71/// Valid resources for explain.
72const EXPLAIN_RESOURCE_NEXT_STEP: &str = "choose issue or trace";
73/// Valid resources for state mutation.
74const SET_RESOURCE_NEXT_STEP: &str = "choose issue";
75/// Filters trace detail reads cannot apply.
76const TRACE_DETAIL_UNSUPPORTED_FLAGS: &[&str] = &[
77    "--name",
78    "--service",
79    "--service-name",
80    "--since",
81    "--user",
82    "--distinct-id",
83    "--trace",
84    "--trace-id",
85    "--level",
86    "--severity",
87    "--search",
88    "--status",
89    "--limit",
90    "--min-duration-ms",
91];
92/// Filters issue detail reads cannot apply.
93const ISSUE_DETAIL_UNSUPPORTED_FLAGS: &[&str] = &[
94    "--name",
95    "--service",
96    "--service-name",
97    "--since",
98    "--user",
99    "--distinct-id",
100    "--trace",
101    "--trace-id",
102    "--level",
103    "--severity",
104    "--search",
105    "--project",
106    "--project-id",
107    "--release",
108    "--environment",
109    "--env",
110    "--status",
111    "--limit",
112    "--min-duration-ms",
113];
114/// Filters action list reads cannot apply.
115const ACTION_LIST_UNSUPPORTED_FLAGS: &[&str] = &[
116    "--trace",
117    "--trace-id",
118    "--level",
119    "--severity",
120    "--search",
121    "--status",
122    "--min-duration-ms",
123];
124
125/// # Errors
126/// Returns [`CliError`] if the command grammar is invalid.
127pub fn parse_command<I, S>(args: I) -> Result<Command, CliError>
128where
129    I: IntoIterator<Item = S>,
130    S: AsRef<str>,
131{
132    let values = args
133        .into_iter()
134        .map(|arg| arg.as_ref().to_owned())
135        .collect::<Vec<_>>();
136    parse_values(values.as_slice())
137}
138
139/// Parses a collected argument slice.
140fn parse_values(values: &[String]) -> Result<Command, CliError> {
141    let args = values.get(1..).ok_or(CliError::UnknownCommand)?;
142    let Some((head, tail)) = args.split_first() else {
143        return Ok(Command::Help {
144            topic: HelpTopic::Root,
145            json: false,
146        });
147    };
148    if is_help_flag(head) {
149        validate_help_flags(tail)?;
150        ensure_no_help_positionals(positional_args(tail).as_slice())?;
151        return Ok(help_command(HelpTopic::Root, tail));
152    }
153    if head == "--json" {
154        return parse_global_json(values, tail);
155    }
156    if is_version_flag(head) {
157        return parse_version(tail);
158    }
159    if head.starts_with('-') {
160        return Err(unknown_flag(head, HELP_NEXT_STEP));
161    }
162    if head == "help" {
163        return parse_help(tail);
164    }
165    if let Some(command) = parse_literal_help(head, tail)? {
166        return Ok(command);
167    }
168    if is_setup_alias(head) && tail.iter().any(|arg| arg == "--create-project") {
169        return parse_setup_create_project(tail);
170    }
171    if contains_help_flag(tail) && !is_log_search_separator_literal(head, tail) {
172        validate_help_flags(tail)?;
173        if let Some(topic) = command_shaped_help_topic(head, tail) {
174            return Ok(help_command(topic, tail));
175        }
176        return Ok(help_command(help_topic(head, tail)?, tail));
177    }
178    match head.as_str() {
179        "login" => parse_login(tail),
180        "logout" => parse_logout(tail),
181        alias if is_setup_alias(alias) => parse_setup(tail),
182        "status" | "health" | "ping" => parse_status(tail),
183        "whoami" | "me" => parse_whoami(tail),
184        "doctor" => parse_doctor(tail),
185        "version" => parse_version(tail),
186        "account" if tail.first().is_some_and(|arg| arg == "usage") => parse_usage(&tail[1..]),
187        alias if auth_namespace::is_namespace(alias) => auth_namespace::parse(tail),
188        alias if auth_namespace::is_help_alias(alias) => parse_help_alias(HelpTopic::Auth, tail),
189        "json" | "output" => parse_help_alias(HelpTopic::Json, tail),
190        alias if is_examples_help_alias(alias) => parse_help_alias(HelpTopic::Examples, tail),
191        alias if is_project_help_alias(alias) => parse_project(tail),
192        "usage" => parse_usage(tail),
193        "support" => parse_support(tail),
194        "investigate" => parse_investigate(tail),
195        "debug-artifacts" => parse_native_debug_artifacts(tail),
196        alias if is_direct_filter_help_alias(alias) => parse_help_alias(HelpTopic::Read, tail),
197        "read" => parse_read(tail),
198        alias if is_read_verb(alias) => parse_read_verb(alias, tail),
199        status if is_known_issue_status(status) && has_issue_id_candidate(tail) => {
200            parse_status_first_issue_id_shortcut(status, tail)
201        }
202        status
203            if is_known_issue_status(status) && has_status_first_issue_resource_candidate(tail) =>
204        {
205            parse_status_first_issue_read(status, tail)
206        }
207        status if is_known_issue_status(status) => parse_bare_issue_status_shortcut(status, tail),
208        alias if is_log_search_shortcut(alias) => {
209            parse_search_shortcut(log_search_shortcut_label(alias), tail)
210        }
211        "log" => parse_read_resource("logs", tail),
212        "release" => parse_read_resource("releases", tail),
213        alias if matches!(alias, "trace" | "span") && !has_position_candidate(tail) => {
214            parse_help_alias(HelpTopic::ReadTrace, tail)
215        }
216        alias if matches!(alias, "traces" | "spans") && has_trace_id_candidate(tail) => {
217            parse_read_resource("trace", tail)
218        }
219        "traces" | "spans" => parse_read_resource("traces", tail),
220        "logs" | "issues" | "errors" | "error" | "exceptions" | "exception" | "actions"
221        | "events" | "event" | "action" | "releases" | "trace" | "issue" => {
222            parse_read_resource(head, tail)
223        }
224        "span" if has_position_candidate(tail) => parse_read_resource("trace", tail),
225        "resolve" | "close" | "ignore" | "reopen" => parse_issue_status_shortcut(head, tail),
226        alias if is_watch_command_alias(alias) => parse_watch(tail),
227        "explain" => parse_explain(tail),
228        "set" => parse_set(tail),
229        id if is_pasted_detail_id(id) => parse_pasted_detail_id(id, tail),
230        _ => Err(unknown_command(head)),
231    }
232}
233
234/// Parses the closed Apple native debug-artifact grammar.
235fn parse_native_debug_artifacts(args: &[String]) -> Result<Command, CliError> {
236    let normalized = move_leading_json_to_tail(args);
237    let Some((operation, tail)) = normalized.split_first() else {
238        return Err(CliError::InvalidNativeDebugCommand);
239    };
240    match operation.as_str() {
241        "upload" => parse_native_debug_upload(tail),
242        "lookup" => parse_native_debug_lookup(tail),
243        _ => Err(CliError::InvalidNativeDebugCommand),
244    }
245}
246
247/// Parses one artifact upload and normalizes its public request scope.
248fn parse_native_debug_upload(args: &[String]) -> Result<Command, CliError> {
249    let Some((path, flags)) = args.split_first() else {
250        return Err(CliError::InvalidNativeDebugCommand);
251    };
252    if path.is_empty() || path.chars().any(char::is_control) || path.starts_with('-') {
253        return Err(CliError::InvalidNativeDebugCommand);
254    }
255    let parsed = parse_native_debug_scope(flags, false)?;
256    Ok(Command::NativeDebugArtifacts {
257        target: crate::NativeDebugArtifactsTarget::Upload(crate::NativeDebugUploadOptions {
258            path: path.clone(),
259            project_id: parsed.project_id,
260            release: parsed.release,
261            environment: parsed.environment,
262            service: parsed.service,
263            expected_image_uuids: parsed.expected_image_uuids,
264            dry_run: parsed.dry_run,
265        }),
266        json: parsed.json,
267    })
268}
269
270/// Parses one exact artifact lookup.
271fn parse_native_debug_lookup(args: &[String]) -> Result<Command, CliError> {
272    let parsed = parse_native_debug_scope(args, true)?;
273    let image_uuid = parsed
274        .image_uuid
275        .and_then(|value| normalize_native_debug_uuid(value.as_str()))
276        .ok_or(CliError::InvalidNativeDebugIdentity)?;
277    let architecture = parsed
278        .architecture
279        .filter(|value| matches!(value.as_str(), "arm64" | "arm64e" | "x86_64"))
280        .ok_or(CliError::InvalidNativeDebugIdentity)?;
281    Ok(Command::NativeDebugArtifacts {
282        target: crate::NativeDebugArtifactsTarget::Lookup(crate::NativeDebugLookupOptions {
283            project_id: parsed.project_id,
284            release: parsed.release,
285            environment: parsed.environment,
286            service: parsed.service,
287            image_uuid,
288            architecture,
289        }),
290        json: parsed.json,
291    })
292}
293
294/// Duplicate-aware native debug-artifact flag accumulator.
295#[derive(Default)]
296struct NativeDebugScope {
297    /// Account-owned project UUID.
298    project_id: String,
299    /// Exact normalized release.
300    release: String,
301    /// Exact normalized environment.
302    environment: String,
303    /// Exact normalized service.
304    service: String,
305    /// Optional lookup image UUID.
306    image_uuid: Option<String>,
307    /// Optional exact image UUID set for upload gating.
308    expected_image_uuids: Vec<String>,
309    /// Optional lookup architecture.
310    architecture: Option<String>,
311    /// Local-only artifact validation.
312    dry_run: bool,
313    /// Machine-readable output selection.
314    json: bool,
315}
316
317/// Parses required scope flags without reflecting malformed values.
318fn parse_native_debug_scope(args: &[String], lookup: bool) -> Result<NativeDebugScope, CliError> {
319    let mut project_id = None;
320    let mut release = None;
321    let mut environment = None;
322    let mut service = None;
323    let mut image_uuid = None;
324    let mut expected_image_uuids = Vec::new();
325    let mut architecture = None;
326    let mut dry_run = false;
327    let mut json = false;
328    let mut index = 0;
329    while let Some(flag) = args.get(index) {
330        if flag == "--json" {
331            if json {
332                return Err(CliError::InvalidNativeDebugCommand);
333            }
334            json = true;
335            index += 1;
336            continue;
337        }
338        if flag == "--dry-run" && !lookup {
339            if dry_run {
340                return Err(CliError::InvalidNativeDebugCommand);
341            }
342            dry_run = true;
343            index += 1;
344            continue;
345        }
346        if flag == "--expect-image-uuid" && !lookup {
347            let value = args
348                .get(index + 1)
349                .and_then(|value| normalize_native_debug_uuid(value.as_str()))
350                .ok_or(CliError::InvalidNativeDebugIdentity)?;
351            if expected_image_uuids
352                .iter()
353                .any(|existing| existing == &value)
354            {
355                return Err(CliError::InvalidNativeDebugIdentity);
356            }
357            expected_image_uuids.push(value);
358            index += 2;
359            continue;
360        }
361        let destination = match flag.as_str() {
362            "--project" => &mut project_id,
363            "--release" => &mut release,
364            "--environment" => &mut environment,
365            "--service" => &mut service,
366            "--image-uuid" if lookup => &mut image_uuid,
367            "--architecture" if lookup => &mut architecture,
368            _ => return Err(CliError::InvalidNativeDebugCommand),
369        };
370        if destination.is_some() {
371            return Err(CliError::InvalidNativeDebugCommand);
372        }
373        let value = args
374            .get(index + 1)
375            .ok_or(CliError::InvalidNativeDebugCommand)?;
376        *destination = Some(value.clone());
377        index += 2;
378    }
379
380    let project_id = project_id
381        .filter(|value| is_canonical_lower_uuid(value))
382        .ok_or(CliError::InvalidNativeDebugCommand)?;
383    let release = normalize_native_scope(release).ok_or(CliError::InvalidNativeDebugCommand)?;
384    let environment =
385        normalize_native_scope(environment).ok_or(CliError::InvalidNativeDebugCommand)?;
386    let service = normalize_native_scope(service).ok_or(CliError::InvalidNativeDebugCommand)?;
387    if lookup != (image_uuid.is_some() && architecture.is_some()) {
388        return Err(CliError::InvalidNativeDebugCommand);
389    }
390    if lookup && (!expected_image_uuids.is_empty() || dry_run) {
391        return Err(CliError::InvalidNativeDebugCommand);
392    }
393    expected_image_uuids.sort();
394    Ok(NativeDebugScope {
395        project_id,
396        release,
397        environment,
398        service,
399        image_uuid,
400        expected_image_uuids,
401        architecture,
402        dry_run,
403        json,
404    })
405}
406
407/// Trims and bounds one public native artifact scope string.
408fn normalize_native_scope(value: Option<String>) -> Option<String> {
409    let value = value?;
410    let trimmed = value.trim();
411    (!trimmed.is_empty() && trimmed.len() <= 256 && !trimmed.chars().any(char::is_control))
412        .then(|| trimmed.to_owned())
413}
414
415/// Validates and canonicalizes one public native debug UUID.
416fn normalize_native_debug_uuid(value: &str) -> Option<String> {
417    if value.len() != 36 {
418        return None;
419    }
420    let mut normalized = String::with_capacity(36);
421    for (index, byte) in value.bytes().enumerate() {
422        if matches!(index, 8 | 13 | 18 | 23) {
423            if byte != b'-' {
424                return None;
425            }
426            normalized.push('-');
427        } else if byte.is_ascii_hexdigit() {
428            normalized.push(char::from(byte.to_ascii_lowercase()));
429        } else {
430            return None;
431        }
432    }
433    Some(normalized)
434}
435
436/// Restricts public UUID inputs to lowercase dashed canonical form.
437fn is_canonical_lower_uuid(value: &str) -> bool {
438    value.len() == 36
439        && value.bytes().enumerate().all(|(index, byte)| {
440            matches!(index, 8 | 13 | 18 | 23) && byte == b'-'
441                || !matches!(index, 8 | 13 | 18 | 23)
442                    && (byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
443        })
444}
445
446/// Parses the closed, read-only issue investigation grammar.
447fn parse_investigate(args: &[String]) -> Result<Command, CliError> {
448    let normalized = move_leading_json_to_tail(args);
449    match normalized.as_slice() {
450        [resource, issue_id]
451            if resource == "issue" && is_safe_investigation_issue_id(issue_id.as_str()) =>
452        {
453            Ok(Command::InvestigateIssue {
454                issue_id: issue_id.clone(),
455                json: false,
456            })
457        }
458        [resource, issue_id, json]
459            if resource == "issue"
460                && is_safe_investigation_issue_id(issue_id.as_str())
461                && json == "--json" =>
462        {
463            Ok(Command::InvestigateIssue {
464                issue_id: issue_id.clone(),
465                json: true,
466            })
467        }
468        _ => Err(CliError::InvalidInvestigationCommand),
469    }
470}
471
472/// Restricts investigation IDs to canonical lowercase dashed UUIDs.
473fn is_safe_investigation_issue_id(value: &str) -> bool {
474    value.len() == 36
475        && value.bytes().enumerate().all(|(index, byte)| {
476            matches!(index, 8 | 13 | 18 | 23) && byte == b'-'
477                || !matches!(index, 8 | 13 | 18 | 23)
478                    && (byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
479        })
480}
481
482/// Parses a leading global `--json` flag.
483fn parse_global_json(values: &[String], tail: &[String]) -> Result<Command, CliError> {
484    if global_json_tail_has_duplicate(tail) {
485        return Err(CliError::DuplicateFlag {
486            flag: "--json",
487            next: "use --json once",
488        });
489    }
490    if tail.is_empty() {
491        return Ok(Command::Help {
492            topic: HelpTopic::Root,
493            json: true,
494        });
495    }
496
497    let mut normalized = Vec::with_capacity(values.len());
498    if let Some(program) = values.first() {
499        normalized.push(program.clone());
500    }
501    normalized.extend(tail.iter().cloned());
502    normalized.push(String::from("--json"));
503    parse_values(normalized.as_slice())
504}
505
506/// Returns whether a global JSON command also contains a JSON mode flag.
507fn global_json_tail_has_duplicate(tail: &[String]) -> bool {
508    if !tail.iter().any(|arg| arg == "--json") {
509        return false;
510    }
511    let Some((command, rest)) = tail.split_first() else {
512        return false;
513    };
514    let Some(separator_index) = literal_log_search_separator_index(command, rest) else {
515        return true;
516    };
517    rest[..separator_index].iter().any(|arg| arg == "--json")
518}
519/// Builds an unknown-resource error with command-specific recovery guidance.
520fn unknown_resource(resource: &str, next: &'static str) -> CliError {
521    CliError::UnknownResource {
522        resource: resource.to_owned(),
523        next,
524    }
525}
526
527/// Builds an unknown-flag error with command-specific recovery guidance.
528fn unknown_flag(flag: &str, next: &'static str) -> CliError {
529    CliError::UnknownFlag {
530        flag: flag.to_owned(),
531        next,
532    }
533}
534
535/// Builds an unknown read resource error with common-term recovery guidance.
536fn unknown_read_resource(resource: &str) -> CliError {
537    unknown_resource(resource, read_resource_next_step(resource))
538}
539
540/// Returns the next step for unsupported read resources.
541fn read_resource_next_step(resource: &str) -> &'static str {
542    match resource {
543        "trace" | "traces" | "span" | "spans" => READ_TRACE_ALIAS_NEXT_STEP,
544        _ => READ_RESOURCE_NEXT_STEP,
545    }
546}
547
548/// Builds an unknown-command error with typo recovery guidance when available.
549fn unknown_command(command: &str) -> CliError {
550    CliError::UnknownCommandName {
551        command: command.to_owned(),
552        next: unknown_command_next_step(command),
553    }
554}
555
556/// Returns a next step for common command typos.
557fn unknown_command_next_step(command: &str) -> &'static str {
558    match command {
559        "logg" | "lgs" => "did you mean logbrew logs?",
560        "action" | "event" | "events" => "did you mean logbrew actions?",
561        "releaze" | "rels" => "did you mean logbrew releases?",
562        "statuz" | "stats" => "did you mean logbrew status?",
563        "error" | "errors" | "exception" | "exceptions" => "did you mean logbrew issues?",
564        "trace" | "traces" | "span" | "spans" => TRACE_COMMAND_NEXT_STEP,
565        "env" | "environment" | "environments" => {
566            "use --environment <environment> with logs, issues, actions, releases, or traces"
567        }
568        alias if auth_namespace::is_help_alias(alias) => "run logbrew help auth",
569        _ => HELP_NEXT_STEP,
570    }
571}
572
573/// Returns whether a word should land on status/health help.
574fn is_status_help_alias(value: &str) -> bool {
575    matches!(value, "status" | "health" | "ping" | "doctor")
576}
577
578/// Returns whether a word should land on example-oriented help.
579fn is_examples_help_alias(value: &str) -> bool {
580    matches!(
581        value,
582        "example" | "examples" | "sample" | "samples" | "recipe" | "recipes"
583    )
584}
585
586/// Returns whether a word should run the non-mutating setup plan.
587fn is_setup_alias(value: &str) -> bool {
588    matches!(value, "setup" | "init" | "install" | "configure" | "sdk")
589}
590
591/// Returns whether a word should land on backend-owned project setup help.
592fn is_project_help_alias(value: &str) -> bool {
593    matches!(value, "project" | "projects")
594}
595
596/// Returns whether a word should use the live watch placeholder flow.
597fn is_watch_command_alias(value: &str) -> bool {
598    matches!(value, "watch" | "tail" | "follow" | "stream")
599}
600
601/// Returns whether a value is a version flag.
602fn is_version_flag(value: &str) -> bool {
603    matches!(value, "--version" | "-V")
604}
605
606/// Parses `login`.
607fn parse_login(args: &[String]) -> Result<Command, CliError> {
608    let flags = parse_flags(args, FlagScope::Login)?;
609    let json = flags.is_json();
610    Ok(Command::Login {
611        provider: flags.login_provider(),
612        open_browser: flags.should_open_browser() && !json,
613        json,
614    })
615}
616
617/// Parses `logout`.
618fn parse_logout(args: &[String]) -> Result<Command, CliError> {
619    let flags = parse_flags(args, FlagScope::Logout)?;
620    Ok(Command::Logout {
621        json: flags.is_json(),
622    })
623}
624
625/// Parses `setup`.
626fn parse_setup(args: &[String]) -> Result<Command, CliError> {
627    if args.iter().any(|arg| arg == "--create-project") {
628        return parse_setup_create_project(args);
629    }
630    let flags = parse_flags(args, FlagScope::Setup)?;
631    Ok(Command::Setup {
632        auto: flags.is_auto(),
633        yes: flags.skip_prompts(),
634        json: flags.is_json(),
635    })
636}
637
638/// Parses the help-only backend project creation shape advertised by setup help.
639fn parse_setup_create_project(args: &[String]) -> Result<Command, CliError> {
640    let mut seen_create_project = false;
641    let mut seen_json = false;
642
643    for arg in args {
644        match arg.as_str() {
645            "--create-project" => {
646                if std::mem::replace(&mut seen_create_project, true) {
647                    return Err(CliError::DuplicateFlag {
648                        flag: "--create-project",
649                        next: "use --create-project once",
650                    });
651                }
652            }
653            "--json" => {
654                if std::mem::replace(&mut seen_json, true) {
655                    return Err(CliError::DuplicateFlag {
656                        flag: "--json",
657                        next: "use --json once",
658                    });
659                }
660            }
661            "--help" | "-h" => {}
662            flag if flag.starts_with('-') => {
663                return Err(unknown_flag(flag, PROJECTS_NEXT_STEP));
664            }
665            argument => {
666                return Err(CliError::UnexpectedArgument {
667                    argument: argument.to_owned(),
668                    command: "setup",
669                    next: PROJECTS_NEXT_STEP,
670                });
671            }
672        }
673    }
674
675    Ok(Command::Help {
676        topic: HelpTopic::Projects,
677        json: seen_json,
678    })
679}
680
681/// Parses backend-owned project commands.
682fn parse_project(args: &[String]) -> Result<Command, CliError> {
683    let normalized = move_leading_json_to_tail(args);
684    if let Some((namespace, tail)) = normalized.split_first()
685        && matches!(namespace.as_str(), "key" | "keys")
686    {
687        let Some((subcommand, create_args)) = tail.split_first() else {
688            return Err(CliError::InvalidProjectsCommand);
689        };
690        if subcommand == "create" {
691            return parse_project_ingest_key_create(create_args);
692        }
693        return Err(CliError::InvalidProjectsCommand);
694    }
695    if let Some((subcommand, tail)) = normalized.split_first()
696        && subcommand == "create"
697    {
698        return parse_project_create(tail);
699    }
700    if let Some((subcommand, tail)) = normalized.split_first()
701        && subcommand == "archive"
702    {
703        return parse_project_archive(tail);
704    }
705    if let Some((subcommand, tail)) = normalized.split_first()
706        && subcommand == "setup"
707        && has_position_candidate(tail)
708    {
709        return parse_project_setup_seen(tail);
710    }
711    match normalized.as_slice() {
712        [] => Ok(Command::Projects { json: false }),
713        [flag] if flag == "--json" => Ok(Command::Projects { json: true }),
714        [_, ..] => Err(CliError::InvalidProjectsCommand),
715    }
716}
717
718/// Parses the closed secure existing-project ingest-key creation grammar.
719fn parse_project_ingest_key_create(args: &[String]) -> Result<Command, CliError> {
720    let Some((project_id, tail)) = args.split_first() else {
721        return Err(CliError::InvalidProjectIngestKeyCreateCommand);
722    };
723    if !is_uuid(project_id) {
724        return Err(CliError::InvalidProjectIngestKeyCreateCommand);
725    }
726    let mut label = None;
727    let mut kind = None;
728    let mut ingest_key_file = None;
729    let mut abandon_retry = false;
730    let mut json = false;
731    let mut index = 0;
732
733    while let Some(argument) = tail.get(index) {
734        let (flag, inline_value) = argument
735            .split_once('=')
736            .map_or((argument.as_str(), None), |(flag, value)| {
737                (flag, Some(value))
738            });
739        match flag {
740            "--label" if label.is_none() => {
741                let value = project_ingest_key_create_flag_value(tail, &mut index, inline_value)?;
742                label = Some(
743                    bounded_project_create_value(value, 120, false)
744                        .ok_or(CliError::InvalidProjectIngestKeyCreateCommand)?,
745                );
746            }
747            "--kind" if kind.is_none() => {
748                let value = project_ingest_key_create_flag_value(tail, &mut index, inline_value)?;
749                kind = Some(match value.trim() {
750                    "sdk" => String::from("sdk"),
751                    "browser" => String::from("browser"),
752                    "server" => String::from("server"),
753                    "cli" => String::from("cli"),
754                    _ => return Err(CliError::InvalidProjectIngestKeyCreateCommand),
755                });
756            }
757            "--ingest-key-file" if ingest_key_file.is_none() => {
758                let value = project_ingest_key_create_flag_value(tail, &mut index, inline_value)?;
759                let trimmed = value.trim();
760                if trimmed.is_empty()
761                    || trimmed.len() > 4096
762                    || trimmed.chars().any(char::is_control)
763                {
764                    return Err(CliError::InvalidProjectIngestKeyCreateCommand);
765                }
766                ingest_key_file = Some(trimmed.to_owned());
767            }
768            "--abandon-retry" if inline_value.is_none() && !abandon_retry => {
769                abandon_retry = true;
770            }
771            "--json" if inline_value.is_none() && !json => json = true,
772            _ => return Err(CliError::InvalidProjectIngestKeyCreateCommand),
773        }
774        index += 1;
775    }
776
777    Ok(Command::ProjectIngestKeyCreate {
778        options: ProjectIngestKeyCreateOptions {
779            project_id: project_id.to_ascii_lowercase(),
780            label: label.unwrap_or_else(|| String::from("CLI-created SDK key")),
781            kind: kind.unwrap_or_else(|| String::from("sdk")),
782            ingest_key_file: ingest_key_file
783                .ok_or(CliError::InvalidProjectIngestKeyCreateCommand)?,
784            abandon_retry,
785        },
786        json,
787    })
788}
789
790/// Takes one existing-project key-create flag value without reflection.
791fn project_ingest_key_create_flag_value<'a>(
792    args: &'a [String],
793    index: &mut usize,
794    inline: Option<&'a str>,
795) -> Result<&'a str, CliError> {
796    if let Some(value) = inline {
797        return Ok(value);
798    }
799    *index += 1;
800    args.get(*index)
801        .map(String::as_str)
802        .filter(|value| !value.starts_with('-'))
803        .ok_or(CliError::InvalidProjectIngestKeyCreateCommand)
804}
805
806/// Parses the closed, explicitly confirmed project archival grammar.
807fn parse_project_archive(args: &[String]) -> Result<Command, CliError> {
808    let normalized = move_leading_json_to_tail(args);
809    let Some((project_id, flags)) = normalized.split_first() else {
810        return Err(CliError::InvalidProjectArchiveCommand);
811    };
812    if !is_uuid(project_id) {
813        return Err(CliError::InvalidProjectArchiveCommand);
814    }
815    let mut yes = false;
816    let mut json = false;
817    for flag in flags {
818        match flag.as_str() {
819            "--yes" if !yes => yes = true,
820            "--json" if !json => json = true,
821            _ => return Err(CliError::InvalidProjectArchiveCommand),
822        }
823    }
824    if !yes {
825        return Err(CliError::InvalidProjectArchiveCommand);
826    }
827    Ok(Command::ProjectArchive {
828        project_id: project_id.to_ascii_lowercase(),
829        json,
830    })
831}
832
833/// Parses the closed secure project creation grammar.
834fn parse_project_create(args: &[String]) -> Result<Command, CliError> {
835    let Some((name, tail)) = args.split_first() else {
836        return Err(CliError::InvalidProjectCreateCommand);
837    };
838    if name.starts_with('-') {
839        return Err(CliError::InvalidProjectCreateCommand);
840    }
841    let name = bounded_project_create_value(name, 120, false)
842        .ok_or(CliError::InvalidProjectCreateCommand)?;
843    if name.starts_with('-') {
844        return Err(CliError::InvalidProjectCreateCommand);
845    }
846    let mut runtime = None;
847    let mut environment = None;
848    let mut ingest_key_file = None;
849    let mut abandon_retry = false;
850    let mut json = false;
851    let mut index = 0;
852
853    while let Some(argument) = tail.get(index) {
854        let (flag, inline_value) = argument
855            .split_once('=')
856            .map_or((argument.as_str(), None), |(flag, value)| {
857                (flag, Some(value))
858            });
859        match flag {
860            "--runtime" if runtime.is_none() => {
861                let value = project_create_flag_value(tail, &mut index, inline_value)?;
862                runtime = optional_project_create_value(value, 64)?;
863            }
864            "--environment" if environment.is_none() => {
865                let value = project_create_flag_value(tail, &mut index, inline_value)?;
866                environment = optional_project_create_value(value, 64)?;
867            }
868            "--ingest-key-file" if ingest_key_file.is_none() => {
869                let value = project_create_flag_value(tail, &mut index, inline_value)?;
870                let trimmed = value.trim();
871                if trimmed.is_empty()
872                    || trimmed.len() > 4096
873                    || trimmed.chars().any(char::is_control)
874                {
875                    return Err(CliError::InvalidProjectCreateCommand);
876                }
877                ingest_key_file = Some(trimmed.to_owned());
878            }
879            "--abandon-retry" if inline_value.is_none() && !abandon_retry => {
880                abandon_retry = true;
881            }
882            "--json" if inline_value.is_none() && !json => json = true,
883            _ => return Err(CliError::InvalidProjectCreateCommand),
884        }
885        index += 1;
886    }
887
888    let ingest_key_file = ingest_key_file.ok_or(CliError::InvalidProjectCreateCommand)?;
889    Ok(Command::ProjectCreate {
890        options: ProjectCreateOptions {
891            name,
892            runtime,
893            environment,
894            ingest_key_file,
895            abandon_retry,
896        },
897        json,
898    })
899}
900
901/// Takes an inline or following project-create flag value without reflection.
902fn project_create_flag_value<'a>(
903    args: &'a [String],
904    index: &mut usize,
905    inline: Option<&'a str>,
906) -> Result<&'a str, CliError> {
907    if let Some(value) = inline {
908        return Ok(value);
909    }
910    *index += 1;
911    args.get(*index)
912        .map(String::as_str)
913        .filter(|value| !value.starts_with('-'))
914        .ok_or(CliError::InvalidProjectCreateCommand)
915}
916
917/// Trims one bounded control-safe project-create field.
918fn bounded_project_create_value(value: &str, limit: usize, allow_blank: bool) -> Option<String> {
919    let value = value.trim();
920    let length = value.chars().count();
921    if value.chars().any(char::is_control) || length > limit || (!allow_blank && length == 0) {
922        return None;
923    }
924    (!value.is_empty()).then(|| value.to_owned())
925}
926
927/// Normalizes one optional field while distinguishing blank from invalid.
928fn optional_project_create_value(value: &str, limit: usize) -> Result<Option<String>, CliError> {
929    let trimmed = value.trim();
930    if trimmed.is_empty() {
931        return Ok(None);
932    }
933    bounded_project_create_value(trimmed, limit, false)
934        .map(Some)
935        .ok_or(CliError::InvalidProjectCreateCommand)
936}
937
938/// Parses `projects setup <project_id>`.
939fn parse_project_setup_seen(args: &[String]) -> Result<Command, CliError> {
940    let (project_id, tail) =
941        take_required_position(args, "project_id", PROJECT_SETUP_SEEN_NEXT_STEP)?;
942    let (options, json) = parse_project_setup_seen_flags(tail.as_slice())?;
943    Ok(Command::ProjectSetupSeen {
944        project_id,
945        options,
946        json,
947    })
948}
949
950/// Parses flags for backend setup seen calls.
951fn parse_project_setup_seen_flags(
952    args: &[String],
953) -> Result<(ProjectSetupSeenOptions, bool), CliError> {
954    let mut options = ProjectSetupSeenOptions::default();
955    let mut json = false;
956    let mut seen = Vec::new();
957    let mut index = 0;
958
959    while let Some(arg) = args.get(index) {
960        let (flag, inline_value) = split_project_setup_seen_inline_value(arg.as_str());
961        match flag {
962            "--json" if inline_value.is_none() => {
963                mark_project_setup_seen_flag(&mut seen, "--json")?;
964                json = true;
965            }
966            "--runtime" => {
967                mark_project_setup_seen_flag(&mut seen, "--runtime")?;
968                options.runtime = Some(project_setup_seen_flag_value(
969                    args,
970                    &mut index,
971                    "--runtime",
972                    inline_value,
973                )?);
974            }
975            "--source" => {
976                mark_project_setup_seen_flag(&mut seen, "--source")?;
977                options.source = Some(validate_project_setup_seen_source(
978                    project_setup_seen_flag_value(args, &mut index, "--source", inline_value)?
979                        .as_str(),
980                )?);
981            }
982            "--environment" | "--env" => {
983                mark_project_setup_seen_flag(&mut seen, "--environment")?;
984                let visible_flag = if flag == "--env" {
985                    "--env"
986                } else {
987                    "--environment"
988                };
989                options.environment = Some(project_setup_seen_flag_value(
990                    args,
991                    &mut index,
992                    visible_flag,
993                    inline_value,
994                )?);
995            }
996            flag if flag.starts_with('-') => {
997                return Err(unknown_flag(flag, PROJECT_SETUP_SEEN_NEXT_STEP));
998            }
999            argument => {
1000                return Err(CliError::UnexpectedArgument {
1001                    argument: argument.to_owned(),
1002                    command: "projects setup",
1003                    next: PROJECT_SETUP_SEEN_NEXT_STEP,
1004                });
1005            }
1006        }
1007        index += 1;
1008    }
1009
1010    Ok((options, json))
1011}
1012
1013/// Splits a value-taking project setup flag.
1014fn split_project_setup_seen_inline_value(flag: &str) -> (&str, Option<&str>) {
1015    flag.split_once('=')
1016        .map_or((flag, None), |(name, value)| (name, Some(value)))
1017}
1018
1019/// Records a project setup flag and rejects duplicate occurrences.
1020fn mark_project_setup_seen_flag(
1021    seen: &mut Vec<&'static str>,
1022    flag: &'static str,
1023) -> Result<(), CliError> {
1024    if seen.contains(&flag) {
1025        return Err(CliError::DuplicateFlag {
1026            flag,
1027            next: project_setup_seen_duplicate_next(flag),
1028        });
1029    }
1030    seen.push(flag);
1031    Ok(())
1032}
1033
1034/// Returns the recovery step for duplicate project setup flags.
1035fn project_setup_seen_duplicate_next(flag: &'static str) -> &'static str {
1036    match flag {
1037        "--json" => "use --json once",
1038        "--runtime" => "use --runtime once",
1039        "--source" => "use --source once",
1040        "--environment" => "use --environment once",
1041        _ => "use the flag once",
1042    }
1043}
1044
1045/// Reads a value for a project setup flag.
1046fn project_setup_seen_flag_value(
1047    args: &[String],
1048    index: &mut usize,
1049    flag: &'static str,
1050    inline_value: Option<&str>,
1051) -> Result<String, CliError> {
1052    if let Some(value) = inline_value {
1053        if value.is_empty() {
1054            return Err(missing_project_setup_seen_flag_value(flag));
1055        }
1056        return Ok(value.to_owned());
1057    }
1058    *index += 1;
1059    let Some(value) = args.get(*index) else {
1060        return Err(missing_project_setup_seen_flag_value(flag));
1061    };
1062    if value.starts_with('-') {
1063        return Err(missing_project_setup_seen_flag_value(flag));
1064    }
1065    Ok(value.clone())
1066}
1067
1068/// Builds a missing-value error for project setup flags.
1069fn missing_project_setup_seen_flag_value(flag: &'static str) -> CliError {
1070    CliError::MissingFlagValue {
1071        flag,
1072        next: project_setup_seen_missing_value_next(flag),
1073    }
1074}
1075
1076/// Returns the recovery step for missing project setup flag values.
1077fn project_setup_seen_missing_value_next(flag: &'static str) -> &'static str {
1078    match flag {
1079        "--runtime" => "provide a value after --runtime",
1080        "--source" => PROJECT_SETUP_SOURCE_NEXT_STEP,
1081        "--environment" => "provide a value after --environment",
1082        "--env" => "provide a value after --env",
1083        _ => "provide a value after the flag",
1084    }
1085}
1086
1087/// Validates setup source values accepted by the public backend contract.
1088fn validate_project_setup_seen_source(source: &str) -> Result<String, CliError> {
1089    match source {
1090        "api" | "cli" | "sdk" => Ok(source.to_owned()),
1091        other => Err(CliError::InvalidSetupSource(other.to_owned())),
1092    }
1093}
1094
1095/// Parses `status`.
1096fn parse_status(args: &[String]) -> Result<Command, CliError> {
1097    let flags = parse_flags(args, FlagScope::Status)?;
1098    Ok(Command::Status {
1099        json: flags.is_json(),
1100    })
1101}
1102
1103/// Parses an authenticated account identity read.
1104fn parse_whoami(args: &[String]) -> Result<Command, CliError> {
1105    let flags = parse_flags(args, FlagScope::WhoAmI)?;
1106    Ok(Command::WhoAmI {
1107        json: flags.is_json(),
1108    })
1109}
1110
1111/// Parses the closed authenticated account-usage read grammar.
1112fn parse_usage(args: &[String]) -> Result<Command, CliError> {
1113    match args {
1114        [] => Ok(Command::Usage { json: false }),
1115        [flag] if flag == "--json" => Ok(Command::Usage { json: true }),
1116        _ => Err(CliError::InvalidUsageCommand),
1117    }
1118}
1119
1120/// Parses bare status-compatible doctor or one strict project-scoped diagnostic.
1121fn parse_doctor(args: &[String]) -> Result<Command, CliError> {
1122    if args.iter().all(|arg| arg == "--json") {
1123        return parse_status(args);
1124    }
1125
1126    let mut project_id = None;
1127    let mut json = false;
1128    let mut index = 0;
1129    while let Some(argument) = args.get(index) {
1130        if let Some(value) = argument
1131            .strip_prefix("--project=")
1132            .or_else(|| argument.strip_prefix("--project-id="))
1133        {
1134            if project_id.is_some() || !is_uuid(value) {
1135                return Err(CliError::InvalidDoctorCommand);
1136            }
1137            project_id = Some(value.to_owned());
1138            index += 1;
1139            continue;
1140        }
1141        match argument.as_str() {
1142            "--json" if !json => json = true,
1143            "--project" | "--project-id" if project_id.is_none() => {
1144                index += 1;
1145                let Some(value) = args.get(index) else {
1146                    return Err(CliError::InvalidDoctorCommand);
1147                };
1148                if !is_uuid(value) {
1149                    return Err(CliError::InvalidDoctorCommand);
1150                }
1151                project_id = Some(value.clone());
1152            }
1153            _ => return Err(CliError::InvalidDoctorCommand),
1154        }
1155        index += 1;
1156    }
1157
1158    project_id.map_or(Err(CliError::InvalidDoctorCommand), |project_id| {
1159        Ok(Command::Doctor { project_id, json })
1160    })
1161}
1162
1163/// Parses `version`.
1164fn parse_version(args: &[String]) -> Result<Command, CliError> {
1165    let flags = parse_flags(args, FlagScope::Version)?;
1166    Ok(Command::Version {
1167        json: flags.is_json(),
1168    })
1169}
1170
1171/// Takes one required positional argument and rejects flags in its place.
1172fn take_required_arg<'a>(
1173    args: &'a [String],
1174    argument: &'static str,
1175    next: &'static str,
1176) -> Result<(&'a str, &'a [String]), CliError> {
1177    let Some((value, rest)) = args.split_first() else {
1178        return Err(CliError::MissingArgument { argument, next });
1179    };
1180    if value.starts_with('-') {
1181        return Err(CliError::MissingArgument { argument, next });
1182    }
1183    Ok((value.as_str(), rest))
1184}
1185
1186/// Moves a leading JSON flag behind required positional arguments.
1187fn move_leading_json_to_tail(args: &[String]) -> Vec<String> {
1188    if args.first().is_some_and(|arg| arg == "--json") {
1189        let mut normalized = Vec::with_capacity(args.len());
1190        normalized.extend(args[1..].iter().cloned());
1191        normalized.push(String::from("--json"));
1192        normalized
1193    } else {
1194        args.to_vec()
1195    }
1196}
1197
1198/// Returns whether a command has a required positional candidate after `--json`.
1199fn has_position_candidate(args: &[String]) -> bool {
1200    move_leading_json_to_tail(args)
1201        .first()
1202        .is_some_and(|arg| !arg.starts_with('-'))
1203}
1204
1205/// Returns whether args begin with an obvious copied trace id after optional `--json`.
1206fn has_trace_id_candidate(args: &[String]) -> bool {
1207    move_leading_json_to_tail(args)
1208        .first()
1209        .is_some_and(|arg| is_trace_id(arg))
1210}
1211
1212/// Takes a required positional argument after tolerating a leading JSON flag.
1213fn take_required_position(
1214    args: &[String],
1215    argument: &'static str,
1216    next: &'static str,
1217) -> Result<(String, Vec<String>), CliError> {
1218    let normalized = move_leading_json_to_tail(args);
1219    let (value, rest) = take_required_arg(normalized.as_slice(), argument, next)?;
1220    Ok((value.to_owned(), rest.to_vec()))
1221}
1222
1223/// Parses `read`.
1224fn parse_read(args: &[String]) -> Result<Command, CliError> {
1225    let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
1226    let resource = normalize_read_resource(resource.as_str());
1227    if is_recency_read_verb(resource) {
1228        return parse_read_verb(resource, rest.as_slice());
1229    }
1230    if is_known_issue_status(resource) && has_status_first_issue_resource_candidate(rest.as_slice())
1231    {
1232        return parse_status_first_issue_read(resource, rest.as_slice());
1233    }
1234    parse_read_resource(resource, rest.as_slice())
1235}
1236
1237/// Normalizes safe singular collection words behind `read`.
1238fn normalize_read_resource(resource: &str) -> &str {
1239    match resource {
1240        "log" => "logs",
1241        "release" => "releases",
1242        _ => resource,
1243    }
1244}
1245
1246/// Parses natural read-only verbs such as `show logs`.
1247fn parse_read_verb(verb: &str, args: &[String]) -> Result<Command, CliError> {
1248    let rewritten_args = recency_count_shortcut_args(verb, args);
1249    let args = rewritten_args.as_deref().unwrap_or(args);
1250    let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
1251    if is_known_issue_status(resource.as_str())
1252        && has_status_first_issue_resource_candidate(rest.as_slice())
1253    {
1254        return parse_status_first_issue_read(resource.as_str(), rest.as_slice());
1255    }
1256    let resource = normalize_read_verb_resource(verb, resource.as_str());
1257    parse_read_resource(resource, rest.as_slice())
1258}
1259
1260/// Rewrites `last 10 logs` to `last logs --limit 10`.
1261fn recency_count_shortcut_args(verb: &str, args: &[String]) -> Option<Vec<String>> {
1262    if !is_recency_read_verb(verb) {
1263        return None;
1264    }
1265    let normalized = move_leading_json_to_tail(args);
1266    let (count, tail) = normalized.split_first().filter(|(count, tail)| {
1267        !tail.is_empty() && count.chars().all(|char| char.is_ascii_digit())
1268    })?;
1269    let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1270    rewritten.push(tail[0].clone());
1271    let rest = &tail[1..];
1272    if let Some(separator_index) = rest.iter().position(|arg| arg == "--") {
1273        rewritten.extend(rest[..separator_index].iter().cloned());
1274        rewritten.push(String::from("--limit"));
1275        rewritten.push(count.clone());
1276        rewritten.extend(rest[separator_index..].iter().cloned());
1277        return Some(rewritten);
1278    }
1279    rewritten.extend(rest.iter().cloned());
1280    rewritten.push(String::from("--limit"));
1281    rewritten.push(count.clone());
1282    Some(rewritten)
1283}
1284
1285/// Returns whether a command is a natural read-only verb.
1286fn is_read_verb(value: &str) -> bool {
1287    matches!(value, "show" | "list" | "get") || is_recency_read_verb(value)
1288}
1289
1290/// Returns whether a command is a recency-flavored read alias.
1291fn is_recency_read_verb(value: &str) -> bool {
1292    matches!(value, "latest" | "recent" | "last" | "newest")
1293}
1294
1295/// Normalizes singular collection words behind natural read verbs.
1296fn normalize_read_verb_resource<'a>(verb: &str, resource: &'a str) -> &'a str {
1297    match (verb, resource) {
1298        ("list" | "show" | "get", "log") => "logs",
1299        (alias, "log") if is_recency_read_verb(alias) => "logs",
1300        (alias, "issue") if is_recency_read_verb(alias) => "issues",
1301        ("list", "issue") => "issues",
1302        ("list" | "show" | "get", "release") => "releases",
1303        (alias, "release") if is_recency_read_verb(alias) => "releases",
1304        _ => resource,
1305    }
1306}
1307
1308/// Returns whether a command is a natural log search shortcut.
1309fn is_log_search_shortcut(command: &str) -> bool {
1310    matches!(command, "search" | "find" | "grep")
1311}
1312
1313/// Returns whether a log search form uses `--` to search help-looking text.
1314fn is_log_search_separator_literal(command: &str, args: &[String]) -> bool {
1315    literal_log_search_separator_index(command, args).is_some()
1316}
1317
1318/// Returns the static argument label for a natural log search shortcut.
1319fn log_search_shortcut_label(command: &str) -> &'static str {
1320    match command {
1321        "find" => "find",
1322        "grep" => "grep",
1323        _ => "search",
1324    }
1325}
1326
1327/// Parses natural log search shortcuts as `logs --search <text>`.
1328fn parse_search_shortcut(label: &'static str, args: &[String]) -> Result<Command, CliError> {
1329    let (query, tail) = take_search_query(args, label)?;
1330    let mut rest = Vec::with_capacity(tail.len() + 2);
1331    if query.starts_with('-') {
1332        rest.push(format!("--search={query}"));
1333    } else {
1334        rest.push(String::from("--search"));
1335        rest.push(query);
1336    }
1337    rest.extend(tail);
1338    parse_read_resource("logs", rest.as_slice())
1339}
1340
1341/// Takes leading search text, allowing unquoted multi-word query shortcuts.
1342fn take_search_query(
1343    args: &[String],
1344    argument: &'static str,
1345) -> Result<(String, Vec<String>), CliError> {
1346    let normalized = move_leading_json_to_tail(args);
1347    if normalized.first().is_some_and(|arg| arg == "--") {
1348        return take_separator_search_query(normalized.as_slice(), argument);
1349    }
1350    let query_word_count = normalized
1351        .iter()
1352        .take_while(|arg| !arg.starts_with('-'))
1353        .count();
1354    if query_word_count == 0 {
1355        return Err(CliError::MissingArgument {
1356            argument,
1357            next: SEARCH_NEXT_STEP,
1358        });
1359    }
1360    let query = normalized[..query_word_count].join(" ");
1361    let tail = normalized[query_word_count..].to_vec();
1362    Ok((query, tail))
1363}
1364
1365/// Takes search text after `--`, allowing literal flag-looking terms.
1366fn take_separator_search_query(
1367    args: &[String],
1368    argument: &'static str,
1369) -> Result<(String, Vec<String>), CliError> {
1370    let words = &args[1..];
1371    if words.is_empty() {
1372        return Err(CliError::MissingArgument {
1373            argument,
1374            next: SEARCH_NEXT_STEP,
1375        });
1376    }
1377    let has_trailing_json_mode = words.len() > 1 && words.last().is_some_and(|arg| arg == "--json");
1378    let query_end = if has_trailing_json_mode {
1379        words.len() - 1
1380    } else {
1381        words.len()
1382    };
1383    let query = words[..query_end].join(" ");
1384    let tail = if has_trailing_json_mode {
1385        vec![String::from("--json")]
1386    } else {
1387        Vec::new()
1388    };
1389    Ok((query, tail))
1390}
1391
1392/// Parses `read` resource arguments or top-level read shortcuts.
1393fn parse_read_resource(resource: &str, rest: &[String]) -> Result<Command, CliError> {
1394    let (target, flags) = match resource {
1395        "logs" => parse_log_list_read(rest)?,
1396        alias if is_issue_collection_alias(alias) && has_issue_id_candidate(rest) => {
1397            return parse_issue_detail_or_status(rest);
1398        }
1399        alias if is_issue_collection_alias(alias) => parse_issue_list_read(rest)?,
1400        alias if is_action_collection_alias(alias) => parse_action_list_read(rest)?,
1401        "releases" => parse_list_read(
1402            ReadTarget::Releases,
1403            rest,
1404            "read releases",
1405            READ_RELEASES_NEXT_STEP,
1406            &[
1407                "--name",
1408                "--user",
1409                "--distinct-id",
1410                "--trace",
1411                "--trace-id",
1412                "--level",
1413                "--severity",
1414                "--search",
1415                "--status",
1416                "--min-duration-ms",
1417            ],
1418        )?,
1419        "traces" | "spans" if has_trace_id_candidate(rest) => {
1420            return parse_trace_detail_or_explain(rest);
1421        }
1422        "traces" | "spans" => parse_trace_list_read(rest)?,
1423        "trace" => return parse_trace_detail_or_explain(rest),
1424        "span" if has_position_candidate(rest) => {
1425            return parse_trace_detail_or_explain(rest);
1426        }
1427        "issue" if has_issue_status_candidate(rest) => parse_issue_list_read(rest)?,
1428        "issue" => return parse_issue_detail_or_status(rest),
1429        other => return Err(unknown_read_resource(other)),
1430    };
1431    let json = flags.is_json();
1432    let options = flags.into_read_options();
1433    validate_read_filters(&target, &options)?;
1434
1435    Ok(Command::Read {
1436        target,
1437        options: Box::new(options),
1438        json,
1439    })
1440}
1441
1442/// Returns whether a resource word is an issue list alias.
1443fn is_issue_collection_alias(value: &str) -> bool {
1444    matches!(
1445        value,
1446        "issues" | "errors" | "error" | "exceptions" | "exception"
1447    )
1448}
1449
1450/// Returns whether a resource word can follow a status-first issue shortcut.
1451fn is_status_first_issue_collection_alias(value: &str) -> bool {
1452    value == "issue" || is_issue_collection_alias(value)
1453}
1454
1455/// Returns whether args begin with an issue collection after a status word.
1456fn has_status_first_issue_resource_candidate(args: &[String]) -> bool {
1457    move_leading_json_to_tail(args)
1458        .first()
1459        .is_some_and(|arg| is_status_first_issue_collection_alias(arg))
1460}
1461
1462/// Returns whether args begin with an issue status after optional `--json`.
1463fn has_issue_status_candidate(args: &[String]) -> bool {
1464    move_leading_json_to_tail(args)
1465        .first()
1466        .is_some_and(|arg| is_known_issue_status(arg))
1467}
1468
1469/// Returns whether a resource word is an action list alias.
1470fn is_action_collection_alias(value: &str) -> bool {
1471    matches!(value, "actions" | "events" | "event" | "action")
1472}
1473
1474/// Parses log lists, accepting natural search and positional severity aliases.
1475fn parse_log_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1476    let args = log_shortcut_args(rest);
1477    parse_list_read(
1478        ReadTarget::Logs,
1479        args.as_slice(),
1480        "read logs",
1481        READ_LOGS_NEXT_STEP,
1482        &[
1483            "--name",
1484            "--user",
1485            "--distinct-id",
1486            "--status",
1487            "--min-duration-ms",
1488        ],
1489    )
1490}
1491
1492/// Parses issue/error lists, accepting a first positional status word.
1493fn parse_issue_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1494    let args = issue_status_shortcut_args(rest);
1495    parse_list_read(
1496        ReadTarget::Issues,
1497        args.as_slice(),
1498        "read issues",
1499        READ_ISSUES_NEXT_STEP,
1500        &[
1501            "--name",
1502            "--user",
1503            "--distinct-id",
1504            "--trace",
1505            "--trace-id",
1506            "--level",
1507            "--severity",
1508            "--search",
1509            "--min-duration-ms",
1510        ],
1511    )
1512}
1513
1514/// Parses `open issues` as `issues --status unresolved`.
1515fn parse_status_first_issue_read(status: &str, args: &[String]) -> Result<Command, CliError> {
1516    let canonical_status = normalize_status(status)?;
1517    let (resource, rest) = take_required_position(args, "resource", READ_ISSUES_NEXT_STEP)?;
1518    let resource = resource.as_str();
1519    if !is_status_first_issue_collection_alias(resource) {
1520        return Err(unknown_read_resource(resource));
1521    }
1522    let resource = if resource == "issue" {
1523        "issues"
1524    } else {
1525        resource
1526    };
1527    let mut rewritten = Vec::with_capacity(rest.len() + 2);
1528    rewritten.push(String::from("--status"));
1529    rewritten.push(canonical_status);
1530    rewritten.extend(rest);
1531    parse_read_resource(resource, rewritten.as_slice())
1532}
1533
1534/// Rewrites `issues open` to `issues --status unresolved`.
1535fn issue_status_shortcut_args(args: &[String]) -> Vec<String> {
1536    let normalized = move_leading_json_to_tail(args);
1537    let Some((status, tail)) = normalized
1538        .split_first()
1539        .and_then(|(status, tail)| normalize_status(status).ok().map(|value| (value, tail)))
1540    else {
1541        return args.to_vec();
1542    };
1543    let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1544    rewritten.push(String::from("--status"));
1545    rewritten.push(status);
1546    rewritten.extend(tail.iter().cloned());
1547    rewritten
1548}
1549
1550/// Parses action/event lists, accepting a first positional as `--name`.
1551fn parse_action_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1552    let args = action_name_shortcut_args(rest);
1553    parse_list_read(
1554        ReadTarget::Actions,
1555        args.as_slice(),
1556        "read actions",
1557        READ_ACTIONS_NEXT_STEP,
1558        ACTION_LIST_UNSUPPORTED_FLAGS,
1559    )
1560}
1561
1562/// Rewrites `events checkout_failed` to `actions --name checkout_failed`.
1563fn action_name_shortcut_args(args: &[String]) -> Vec<String> {
1564    let normalized = move_leading_json_to_tail(args);
1565    let Some((name, tail)) = normalized
1566        .split_first()
1567        .filter(|(name, _)| !name.starts_with('-') && !is_read_filter_word(name))
1568    else {
1569        return args.to_vec();
1570    };
1571    let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1572    rewritten.push(String::from("--name"));
1573    rewritten.push(name.clone());
1574    rewritten.extend(tail.iter().cloned());
1575    rewritten
1576}
1577
1578/// Returns whether args start with an obvious issue id after optional `--json`.
1579fn has_issue_id_candidate(args: &[String]) -> bool {
1580    move_leading_json_to_tail(args)
1581        .first()
1582        .is_some_and(|arg| is_issue_id(arg))
1583}
1584
1585/// Parses issue detail reads and issue-first mutation shortcuts.
1586fn parse_issue_detail_or_status(args: &[String]) -> Result<Command, CliError> {
1587    let (id, tail) = take_required_position(args, "issue_id", "provide an issue id")?;
1588    if has_issue_status_action(tail.as_slice()) {
1589        return parse_issue_first_status_shortcut(id, tail.as_slice());
1590    }
1591    if let Some(command) =
1592        parse_detail_explain_suffix(ExplainTarget::Issue(id.clone()), tail.as_slice())?
1593    {
1594        return Ok(command);
1595    }
1596    let target = ReadTarget::Issue(id);
1597    let flags = parse_detail_read_flags(
1598        tail.as_slice(),
1599        "read issue",
1600        READ_ISSUE_NEXT_STEP,
1601        ISSUE_DETAIL_UNSUPPORTED_FLAGS,
1602    )?;
1603    let json = flags.is_json();
1604    let options = flags.into_read_options();
1605    validate_read_filters(&target, &options)?;
1606
1607    Ok(Command::Read {
1608        target,
1609        options: Box::new(options),
1610        json,
1611    })
1612}
1613
1614/// Parses a list read after rejecting filters the target cannot apply.
1615fn parse_list_read(
1616    target: ReadTarget,
1617    args: &[String],
1618    command: &'static str,
1619    next: &'static str,
1620    unsupported_flags: &[&str],
1621) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1622    reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
1623    Ok((target, parse_flags(args, FlagScope::Read)?))
1624}
1625
1626/// Rejects target-inapplicable read filters before parsing values.
1627fn reject_unsupported_read_flags(
1628    args: &[String],
1629    command: &'static str,
1630    next: &'static str,
1631    unsupported_flags: &[&str],
1632) -> Result<(), CliError> {
1633    let mut index = 0;
1634    let mut seen = Vec::new();
1635    while let Some(arg) = args.get(index) {
1636        let (flag, inline_value) = arg
1637            .split_once('=')
1638            .map_or((arg.as_str(), None), |(name, value)| (name, Some(value)));
1639        if !is_read_value_flag(flag) {
1640            if flag == "--json" && inline_value.is_none() {
1641                if seen.contains(&"--json") {
1642                    return Ok(());
1643                }
1644                seen.push("--json");
1645                index += 1;
1646                continue;
1647            }
1648            if inline_value.is_some() && is_simple_flag(flag) {
1649                return Err(CliError::UnsupportedFlag {
1650                    flag: arg.to_owned(),
1651                    command,
1652                    next,
1653                });
1654            }
1655            if arg.starts_with('-') {
1656                return Err(unknown_flag(arg, next));
1657            }
1658            return Ok(());
1659        }
1660        if unsupported_flags.contains(&flag) {
1661            return Err(CliError::UnsupportedFlag {
1662                flag: user_facing_read_flag(flag).to_owned(),
1663                command,
1664                next,
1665            });
1666        }
1667        if let Some(canonical) = read_value_canonical_flag(flag) {
1668            if seen.contains(&canonical) {
1669                return Ok(());
1670            }
1671            seen.push(canonical);
1672        }
1673        if inline_value.is_some_and(str::is_empty) {
1674            return Ok(());
1675        }
1676        if inline_value.is_some_and(|value| has_invalid_supported_read_value(flag, value)) {
1677            return Ok(());
1678        }
1679        if inline_value.is_none() {
1680            let Some(value) = args.get(index + 1) else {
1681                return Ok(());
1682            };
1683            if value.starts_with('-') {
1684                return Ok(());
1685            }
1686            if has_invalid_supported_read_value(flag, value) {
1687                return Ok(());
1688            }
1689            index += 1;
1690        }
1691        index += 1;
1692    }
1693    Ok(())
1694}
1695
1696/// Returns the duplicate-tracking key for a read value flag.
1697fn read_value_canonical_flag(flag: &str) -> Option<&'static str> {
1698    let canonical = match flag {
1699        "--name" => "--name",
1700        "--service" | "--service-name" => "--service",
1701        "--since" => "--since",
1702        "--user" | "--distinct-id" => "--user",
1703        "--trace" | "--trace-id" => "--trace",
1704        "--level" | "--severity" => "--severity",
1705        "--search" => "--search",
1706        "--project" | "--project-id" => "--project",
1707        "--release" => "--release",
1708        "--environment" | "--env" => "--environment",
1709        "--status" => "--status",
1710        "--limit" => "--limit",
1711        "--min-duration-ms" => "--min-duration-ms",
1712        "--pagination" => "--pagination",
1713        "--cursor-time" => "--cursor-time",
1714        "--cursor-id" => "--cursor-id",
1715        _ => return None,
1716    };
1717    Some(canonical)
1718}
1719
1720/// Returns the canonical flag name to show in read-filter recovery output.
1721fn user_facing_read_flag(flag: &str) -> &str {
1722    match flag {
1723        "--level" => "--severity",
1724        "--service-name" => "--service",
1725        other => other,
1726    }
1727}
1728
1729/// Returns whether a supported read flag has a value that should be reported first.
1730fn has_invalid_supported_read_value(flag: &str, value: &str) -> bool {
1731    match flag {
1732        "--level" | "--severity" => !is_known_log_level(value),
1733        "--status" => !is_known_issue_status(value),
1734        "--limit" => value.parse::<u32>().map_or(true, |limit| limit == 0),
1735        "--min-duration-ms" => validate_min_duration(value).is_err(),
1736        "--pagination" => value != "cursor",
1737        _ => false,
1738    }
1739}
1740
1741/// Returns whether a value is in the log-level vocabulary.
1742fn is_known_log_level(value: &str) -> bool {
1743    normalize_log_level(value).is_ok()
1744}
1745
1746/// Returns whether a positional log search word should stay a recoverable error.
1747fn is_ambiguous_log_search_word(value: &str) -> bool {
1748    is_read_filter_word(value) || value.contains('@') || is_trace_id(value)
1749}
1750
1751/// Returns whether a value is in the issue-status vocabulary.
1752fn is_known_issue_status(value: &str) -> bool {
1753    normalize_status(value).is_ok()
1754}
1755
1756/// Returns whether a flag is a value-taking read filter.
1757fn is_read_value_flag(flag: &str) -> bool {
1758    matches!(
1759        flag,
1760        "--name"
1761            | "--service"
1762            | "--service-name"
1763            | "--since"
1764            | "--user"
1765            | "--distinct-id"
1766            | "--trace"
1767            | "--trace-id"
1768            | "--level"
1769            | "--severity"
1770            | "--search"
1771            | "--project"
1772            | "--project-id"
1773            | "--release"
1774            | "--environment"
1775            | "--env"
1776            | "--status"
1777            | "--limit"
1778            | "--min-duration-ms"
1779            | "--pagination"
1780            | "--cursor-time"
1781            | "--cursor-id"
1782    )
1783}
1784
1785/// Parses a trailing `explain` action after an issue or trace detail id.
1786fn parse_detail_explain_suffix(
1787    target: ExplainTarget,
1788    args: &[String],
1789) -> Result<Option<Command>, CliError> {
1790    let normalized = move_leading_json_to_tail(args);
1791    if normalized.first().is_none_or(|arg| arg != "explain") {
1792        return Ok(None);
1793    }
1794    Ok(Some(Command::Explain {
1795        target,
1796        json: parse_flags(&normalized[1..], FlagScope::Explain)?.is_json(),
1797    }))
1798}
1799
1800/// Parses detail read filters after rejecting list-only filters.
1801fn parse_detail_read_flags(
1802    args: &[String],
1803    command: &'static str,
1804    next: &'static str,
1805    unsupported_flags: &[&str],
1806) -> Result<crate::flags::Flags, CliError> {
1807    reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
1808    parse_flags(args, FlagScope::Read)
1809}
1810
1811/// Parses an obvious pasted issue or trace id as a detail read shortcut.
1812fn parse_pasted_detail_id(id: &str, args: &[String]) -> Result<Command, CliError> {
1813    if is_issue_id(id) && has_issue_status_action(args) {
1814        return parse_issue_first_status_shortcut(id.to_owned(), args);
1815    }
1816    let explain_args = move_leading_json_to_tail(args);
1817    if explain_args.first().is_some_and(|arg| arg == "explain") {
1818        let target = infer_explain_target(id).ok_or_else(|| unknown_command(id))?;
1819        return Ok(Command::Explain {
1820            target,
1821            json: parse_flags(&explain_args[1..], FlagScope::Explain)?.is_json(),
1822        });
1823    }
1824    let (target, flags) = if is_trace_id(id) {
1825        (
1826            ReadTarget::Trace(id.to_owned()),
1827            parse_detail_read_flags(
1828                args,
1829                "read trace",
1830                READ_TRACE_NEXT_STEP,
1831                TRACE_DETAIL_UNSUPPORTED_FLAGS,
1832            )?,
1833        )
1834    } else if is_issue_id(id) {
1835        (
1836            ReadTarget::Issue(id.to_owned()),
1837            parse_detail_read_flags(
1838                args,
1839                "read issue",
1840                READ_ISSUE_NEXT_STEP,
1841                ISSUE_DETAIL_UNSUPPORTED_FLAGS,
1842            )?,
1843        )
1844    } else {
1845        return Err(unknown_command(id));
1846    };
1847    let json = flags.is_json();
1848    let options = flags.into_read_options();
1849    validate_read_filters(&target, &options)?;
1850
1851    Ok(Command::Read {
1852        target,
1853        options: Box::new(options),
1854        json,
1855    })
1856}
1857
1858/// Rejects filters that a read endpoint would otherwise ignore.
1859fn validate_read_filters(target: &ReadTarget, filters: &ReadOptions) -> Result<(), CliError> {
1860    let unsupported = match target {
1861        ReadTarget::Logs => filters
1862            .first_log_unsupported_flag()
1863            .map(|flag| (flag, "read logs", READ_LOGS_NEXT_STEP)),
1864        ReadTarget::Issues => filters
1865            .first_issue_list_unsupported_flag()
1866            .map(|flag| (flag, "read issues", READ_ISSUES_NEXT_STEP)),
1867        ReadTarget::Actions => filters
1868            .first_action_unsupported_flag()
1869            .map(|flag| (flag, "read actions", READ_ACTIONS_NEXT_STEP)),
1870        ReadTarget::Releases => filters
1871            .first_release_unsupported_flag()
1872            .map(|flag| (flag, "read releases", READ_RELEASES_NEXT_STEP)),
1873        ReadTarget::Traces => filters
1874            .first_trace_list_unsupported_flag()
1875            .map(|flag| (flag, "read traces", READ_TRACES_NEXT_STEP)),
1876        ReadTarget::Trace(_) => filters
1877            .first_trace_detail_unsupported_flag()
1878            .map(|flag| (flag, "read trace", READ_TRACE_NEXT_STEP)),
1879        ReadTarget::Issue(_) => filters
1880            .first_issue_detail_unsupported_flag()
1881            .map(|flag| (flag, "read issue", READ_ISSUE_NEXT_STEP)),
1882    };
1883
1884    if let Some((flag, command, next)) = unsupported {
1885        return Err(CliError::UnsupportedFlag {
1886            flag: flag.to_owned(),
1887            command,
1888            next,
1889        });
1890    }
1891    match target {
1892        ReadTarget::Logs => validate_read_cursor(filters, CliError::InvalidLogCursor)?,
1893        ReadTarget::Actions => validate_read_cursor(filters, CliError::InvalidActionCursor)?,
1894        ReadTarget::Issues => validate_read_cursor(filters, CliError::InvalidIssueCursor)?,
1895        ReadTarget::Releases | ReadTarget::Traces | ReadTarget::Trace(_) | ReadTarget::Issue(_) => {
1896        }
1897    }
1898    Ok(())
1899}
1900
1901/// Validates an explicit first-page or continuation cursor shape.
1902fn validate_read_cursor(
1903    filters: &ReadOptions,
1904    invalid_cursor: fn(String) -> CliError,
1905) -> Result<(), CliError> {
1906    match (
1907        filters.pagination.as_deref(),
1908        filters.cursor_time.as_ref(),
1909        filters.cursor_id.as_ref(),
1910    ) {
1911        (None | Some("cursor"), None, None) | (Some("cursor"), Some(_), Some(_)) => Ok(()),
1912        (None, _, _) => Err(invalid_cursor(String::from(
1913            "cursor fields require --pagination cursor",
1914        ))),
1915        (Some("cursor"), _, _) => Err(invalid_cursor(String::from(
1916            "--cursor-time and --cursor-id must be used together",
1917        ))),
1918        (Some(_), _, _) => Err(CliError::UnknownPagination),
1919    }
1920}
1921
1922/// Parses `explain`.
1923fn parse_explain(args: &[String]) -> Result<Command, CliError> {
1924    let (resource, rest) = take_required_position(args, "resource", EXPLAIN_RESOURCE_NEXT_STEP)?;
1925    let (target, tail) = match resource.as_str() {
1926        "issue" => {
1927            let (id, tail) =
1928                take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1929            (ExplainTarget::Issue(id), tail)
1930        }
1931        "trace" => {
1932            let (id, tail) =
1933                take_required_position(rest.as_slice(), "trace_id", "provide a trace id")?;
1934            (ExplainTarget::Trace(id), tail)
1935        }
1936        other => {
1937            if let Some(target) = infer_explain_target(other) {
1938                (target, rest)
1939            } else {
1940                return Err(unknown_resource(other, EXPLAIN_RESOURCE_NEXT_STEP));
1941            }
1942        }
1943    };
1944    let flags = parse_flags(tail.as_slice(), FlagScope::Explain)?;
1945    Ok(Command::Explain {
1946        target,
1947        json: flags.is_json(),
1948    })
1949}
1950
1951/// Parses `set`.
1952fn parse_set(args: &[String]) -> Result<Command, CliError> {
1953    let (resource, rest) = take_required_position(args, "resource", SET_RESOURCE_NEXT_STEP)?;
1954    if resource != "issue" {
1955        return Err(unknown_resource(resource.as_str(), SET_RESOURCE_NEXT_STEP));
1956    }
1957    let (id, rest) = take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1958    let (status, tail) =
1959        take_required_position(rest.as_slice(), "status", ISSUE_STATUS_ARGUMENT_NEXT_STEP)?;
1960    let status = normalize_status(status.as_str())?;
1961    let flags = parse_flags(tail.as_slice(), FlagScope::Set)?;
1962
1963    Ok(Command::Set {
1964        target: SetTarget::IssueStatus { id, status },
1965        json: flags.is_json(),
1966    })
1967}