Skip to main content

logbrew_cli/
parser.rs

1//! CLI command grammar.
2
3mod help_topics;
4mod issue_shortcuts;
5mod log_shortcuts;
6mod watch;
7
8use help_topics::{
9    command_shaped_help_topic, contains_help_flag, ensure_no_help_positionals, help_command,
10    help_topic, is_direct_filter_help_alias, is_help_flag, parse_help, parse_help_alias,
11    parse_literal_help, positional_args, validate_help_flags,
12};
13use issue_shortcuts::{
14    has_issue_status_action, is_issue_status_action_alias, parse_bare_issue_status_shortcut,
15    parse_issue_first_status_shortcut, parse_issue_status_shortcut,
16    parse_status_first_issue_id_shortcut,
17};
18use log_shortcuts::{literal_log_search_separator_index, log_shortcut_args};
19use watch::parse_watch;
20
21use crate::flags::{
22    FlagScope, is_read_filter_word, is_simple_flag, normalize_log_level, normalize_status,
23    parse_flags,
24};
25use crate::ids::{infer_explain_target, is_issue_id, is_pasted_detail_id, is_trace_id};
26use crate::{
27    CliError, Command, ExplainTarget, HelpTopic, ISSUE_STATUS_ARGUMENT_NEXT_STEP,
28    ProjectSetupSeenOptions, ReadOptions, ReadTarget, SetTarget, auth_namespace,
29};
30
31/// Standard next step for malformed help invocations.
32const HELP_NEXT_STEP: &str = "run logbrew --help";
33/// Valid resources for historical reads.
34const READ_RESOURCE_NEXT_STEP: &str = "choose one of logs, issues, actions, releases, trace, issue";
35/// Recovery hint for users who type plural trace resources.
36const READ_TRACE_ALIAS_NEXT_STEP: &str =
37    "use singular trace with an id: logbrew read trace <trace_id>";
38/// Recovery hint for users who type trace terminology as a top-level command.
39const TRACE_COMMAND_NEXT_STEP: &str =
40    "use logbrew trace <trace_id> or logbrew explain trace <trace_id>";
41/// Help for trace detail reads.
42const READ_TRACE_NEXT_STEP: &str = "run logbrew read trace --help";
43/// Help for issue detail reads.
44const READ_ISSUE_NEXT_STEP: &str = "run logbrew read issue --help";
45/// Help for log list reads.
46const READ_LOGS_NEXT_STEP: &str = "run logbrew read logs --help";
47/// Recovery hint for natural log search shortcuts.
48const SEARCH_NEXT_STEP: &str = "provide search text or run logbrew logs --help";
49/// Help for issue list reads.
50const READ_ISSUES_NEXT_STEP: &str = "run logbrew read issues --help";
51/// Help for action list reads.
52const READ_ACTIONS_NEXT_STEP: &str = "run logbrew read actions --help";
53/// Help for release list reads.
54const READ_RELEASES_NEXT_STEP: &str = "run logbrew read releases --help";
55/// Help for backend-owned project setup discovery.
56const PROJECTS_NEXT_STEP: &str = "run logbrew projects --help";
57/// Help for backend-owned project setup seen calls.
58const PROJECT_SETUP_SEEN_NEXT_STEP: &str = "run logbrew projects setup <project_id> --help";
59/// Valid setup source values for setup seen calls.
60const PROJECT_SETUP_SOURCE_NEXT_STEP: &str = "use --source api, cli, or sdk";
61/// Valid resources for live watch.
62const WATCH_RESOURCE_NEXT_STEP: &str = "choose logs, issues, actions, or omit a resource";
63/// Valid resources for explain.
64const EXPLAIN_RESOURCE_NEXT_STEP: &str = "choose issue or trace";
65/// Valid resources for state mutation.
66const SET_RESOURCE_NEXT_STEP: &str = "choose issue";
67/// Filters trace detail reads cannot apply.
68const TRACE_DETAIL_UNSUPPORTED_FLAGS: &[&str] = &[
69    "--name",
70    "--since",
71    "--user",
72    "--distinct-id",
73    "--trace",
74    "--trace-id",
75    "--level",
76    "--severity",
77    "--search",
78    "--status",
79    "--limit",
80];
81/// Filters issue detail reads cannot apply.
82const ISSUE_DETAIL_UNSUPPORTED_FLAGS: &[&str] = &[
83    "--name",
84    "--since",
85    "--user",
86    "--distinct-id",
87    "--trace",
88    "--trace-id",
89    "--level",
90    "--severity",
91    "--search",
92    "--project",
93    "--project-id",
94    "--release",
95    "--environment",
96    "--env",
97    "--status",
98    "--limit",
99];
100/// Filters action list reads cannot apply.
101const ACTION_LIST_UNSUPPORTED_FLAGS: &[&str] = &[
102    "--trace",
103    "--trace-id",
104    "--level",
105    "--severity",
106    "--search",
107    "--status",
108];
109
110/// # Errors
111/// Returns [`CliError`] if the command grammar is invalid.
112pub fn parse_command<I, S>(args: I) -> Result<Command, CliError>
113where
114    I: IntoIterator<Item = S>,
115    S: AsRef<str>,
116{
117    let values = args
118        .into_iter()
119        .map(|arg| arg.as_ref().to_owned())
120        .collect::<Vec<_>>();
121    parse_values(values.as_slice())
122}
123
124/// Parses a collected argument slice.
125fn parse_values(values: &[String]) -> Result<Command, CliError> {
126    let args = values.get(1..).ok_or(CliError::UnknownCommand)?;
127    let Some((head, tail)) = args.split_first() else {
128        return Ok(Command::Help {
129            topic: HelpTopic::Root,
130            json: false,
131        });
132    };
133    if is_help_flag(head) {
134        validate_help_flags(tail)?;
135        ensure_no_help_positionals(positional_args(tail).as_slice())?;
136        return Ok(help_command(HelpTopic::Root, tail));
137    }
138    if head == "--json" {
139        return parse_global_json(values, tail);
140    }
141    if is_version_flag(head) {
142        return parse_version(tail);
143    }
144    if head.starts_with('-') {
145        return Err(unknown_flag(head, HELP_NEXT_STEP));
146    }
147    if head == "help" {
148        return parse_help(tail);
149    }
150    if let Some(command) = parse_literal_help(head, tail)? {
151        return Ok(command);
152    }
153    if is_setup_alias(head) && tail.iter().any(|arg| arg == "--create-project") {
154        return parse_setup_create_project(tail);
155    }
156    if contains_help_flag(tail) && !is_log_search_separator_literal(head, tail) {
157        validate_help_flags(tail)?;
158        if let Some(topic) = command_shaped_help_topic(head, tail) {
159            return Ok(help_command(topic, tail));
160        }
161        return Ok(help_command(help_topic(head, tail)?, tail));
162    }
163    match head.as_str() {
164        "login" => parse_login(tail),
165        "logout" => parse_logout(tail),
166        alias if is_setup_alias(alias) => parse_setup(tail),
167        "status" | "whoami" | "me" | "health" | "ping" | "doctor" => parse_status(tail),
168        "version" => parse_version(tail),
169        "account" if tail.first().is_some_and(|arg| arg == "usage") => {
170            parse_discovery_help(HelpTopic::Usage, &tail[1..])
171        }
172        alias if auth_namespace::is_namespace(alias) => auth_namespace::parse(tail),
173        alias if auth_namespace::is_help_alias(alias) => parse_help_alias(HelpTopic::Auth, tail),
174        "json" | "output" => parse_help_alias(HelpTopic::Json, tail),
175        alias if is_examples_help_alias(alias) => parse_help_alias(HelpTopic::Examples, tail),
176        alias if is_project_help_alias(alias) => parse_project(tail),
177        "usage" => parse_discovery_help(HelpTopic::Usage, tail),
178        alias if is_direct_filter_help_alias(alias) => parse_help_alias(HelpTopic::Read, tail),
179        "read" => parse_read(tail),
180        alias if is_read_verb(alias) => parse_read_verb(alias, tail),
181        status if is_known_issue_status(status) && has_issue_id_candidate(tail) => {
182            parse_status_first_issue_id_shortcut(status, tail)
183        }
184        status
185            if is_known_issue_status(status) && has_status_first_issue_resource_candidate(tail) =>
186        {
187            parse_status_first_issue_read(status, tail)
188        }
189        status if is_known_issue_status(status) => parse_bare_issue_status_shortcut(status, tail),
190        alias if is_log_search_shortcut(alias) => {
191            parse_search_shortcut(log_search_shortcut_label(alias), tail)
192        }
193        "log" => parse_read_resource("logs", tail),
194        "release" => parse_read_resource("releases", tail),
195        alias if is_trace_term(alias) && !has_position_candidate(tail) => {
196            parse_help_alias(HelpTopic::ReadTrace, tail)
197        }
198        "logs" | "issues" | "errors" | "error" | "exceptions" | "exception" | "actions"
199        | "events" | "event" | "action" | "releases" | "trace" | "issue" => {
200            parse_read_resource(head, tail)
201        }
202        "traces" | "span" | "spans" if has_position_candidate(tail) => {
203            parse_read_resource("trace", tail)
204        }
205        "resolve" | "close" | "ignore" | "reopen" => parse_issue_status_shortcut(head, tail),
206        alias if is_watch_command_alias(alias) => parse_watch(tail),
207        "explain" => parse_explain(tail),
208        "set" => parse_set(tail),
209        id if is_pasted_detail_id(id) => parse_pasted_detail_id(id, tail),
210        _ => Err(unknown_command(head)),
211    }
212}
213
214/// Parses non-mutating discovery help for backend-owned future workflows.
215fn parse_discovery_help(topic: HelpTopic, args: &[String]) -> Result<Command, CliError> {
216    validate_help_flags(args)?;
217    Ok(Command::Help {
218        topic,
219        json: args.iter().any(|arg| arg == "--json"),
220    })
221}
222
223/// Parses a leading global `--json` flag.
224fn parse_global_json(values: &[String], tail: &[String]) -> Result<Command, CliError> {
225    if global_json_tail_has_duplicate(tail) {
226        return Err(CliError::DuplicateFlag {
227            flag: "--json",
228            next: "use --json once",
229        });
230    }
231    if tail.is_empty() {
232        return Ok(Command::Help {
233            topic: HelpTopic::Root,
234            json: true,
235        });
236    }
237
238    let mut normalized = Vec::with_capacity(values.len());
239    if let Some(program) = values.first() {
240        normalized.push(program.clone());
241    }
242    normalized.extend(tail.iter().cloned());
243    normalized.push(String::from("--json"));
244    parse_values(normalized.as_slice())
245}
246
247/// Returns whether a global JSON command also contains a JSON mode flag.
248fn global_json_tail_has_duplicate(tail: &[String]) -> bool {
249    if !tail.iter().any(|arg| arg == "--json") {
250        return false;
251    }
252    let Some((command, rest)) = tail.split_first() else {
253        return false;
254    };
255    let Some(separator_index) = literal_log_search_separator_index(command, rest) else {
256        return true;
257    };
258    rest[..separator_index].iter().any(|arg| arg == "--json")
259}
260/// Builds an unknown-resource error with command-specific recovery guidance.
261fn unknown_resource(resource: &str, next: &'static str) -> CliError {
262    CliError::UnknownResource {
263        resource: resource.to_owned(),
264        next,
265    }
266}
267
268/// Builds an unknown-flag error with command-specific recovery guidance.
269fn unknown_flag(flag: &str, next: &'static str) -> CliError {
270    CliError::UnknownFlag {
271        flag: flag.to_owned(),
272        next,
273    }
274}
275
276/// Builds an unknown read resource error with common-term recovery guidance.
277fn unknown_read_resource(resource: &str) -> CliError {
278    unknown_resource(resource, read_resource_next_step(resource))
279}
280
281/// Returns the next step for unsupported read resources.
282fn read_resource_next_step(resource: &str) -> &'static str {
283    match resource {
284        "trace" | "traces" | "span" | "spans" => READ_TRACE_ALIAS_NEXT_STEP,
285        _ => READ_RESOURCE_NEXT_STEP,
286    }
287}
288
289/// Builds an unknown-command error with typo recovery guidance when available.
290fn unknown_command(command: &str) -> CliError {
291    CliError::UnknownCommandName {
292        command: command.to_owned(),
293        next: unknown_command_next_step(command),
294    }
295}
296
297/// Returns a next step for common command typos.
298fn unknown_command_next_step(command: &str) -> &'static str {
299    match command {
300        "logg" | "lgs" => "did you mean logbrew logs?",
301        "action" | "event" | "events" => "did you mean logbrew actions?",
302        "releaze" | "rels" => "did you mean logbrew releases?",
303        "statuz" | "stats" => "did you mean logbrew status?",
304        "error" | "errors" | "exception" | "exceptions" => "did you mean logbrew issues?",
305        "trace" | "traces" | "span" | "spans" => TRACE_COMMAND_NEXT_STEP,
306        "env" | "environment" | "environments" => {
307            "use --environment <environment> with logs, issues, actions, releases, or traces"
308        }
309        alias if auth_namespace::is_help_alias(alias) => "run logbrew help auth",
310        _ => HELP_NEXT_STEP,
311    }
312}
313
314/// Returns whether a word should land on status/health help.
315fn is_status_help_alias(value: &str) -> bool {
316    matches!(value, "status" | "health" | "ping" | "doctor")
317}
318
319/// Returns whether a word should land on example-oriented help.
320fn is_examples_help_alias(value: &str) -> bool {
321    matches!(
322        value,
323        "example" | "examples" | "sample" | "samples" | "recipe" | "recipes"
324    )
325}
326
327/// Returns whether a word should run the non-mutating setup plan.
328fn is_setup_alias(value: &str) -> bool {
329    matches!(value, "setup" | "init" | "install" | "configure" | "sdk")
330}
331
332/// Returns whether a word should land on backend-owned project setup help.
333fn is_project_help_alias(value: &str) -> bool {
334    matches!(value, "project" | "projects")
335}
336
337/// Returns whether a word should use the live watch placeholder flow.
338fn is_watch_command_alias(value: &str) -> bool {
339    matches!(value, "watch" | "tail" | "follow" | "stream")
340}
341
342/// Returns whether a word names trace/span vocabulary.
343fn is_trace_term(value: &str) -> bool {
344    matches!(value, "trace" | "traces" | "span" | "spans")
345}
346
347/// Returns whether a value is a version flag.
348fn is_version_flag(value: &str) -> bool {
349    matches!(value, "--version" | "-V")
350}
351
352/// Parses `login`.
353fn parse_login(args: &[String]) -> Result<Command, CliError> {
354    let flags = parse_flags(args, FlagScope::Login)?;
355    let json = flags.is_json();
356    Ok(Command::Login {
357        open_browser: flags.should_open_browser() && !json,
358        json,
359    })
360}
361
362/// Parses `logout`.
363fn parse_logout(args: &[String]) -> Result<Command, CliError> {
364    let flags = parse_flags(args, FlagScope::Logout)?;
365    Ok(Command::Logout {
366        json: flags.is_json(),
367    })
368}
369
370/// Parses `setup`.
371fn parse_setup(args: &[String]) -> Result<Command, CliError> {
372    if args.iter().any(|arg| arg == "--create-project") {
373        return parse_setup_create_project(args);
374    }
375    let flags = parse_flags(args, FlagScope::Setup)?;
376    Ok(Command::Setup {
377        auto: flags.is_auto(),
378        yes: flags.skip_prompts(),
379        json: flags.is_json(),
380    })
381}
382
383/// Parses the help-only backend project creation shape advertised by setup help.
384fn parse_setup_create_project(args: &[String]) -> Result<Command, CliError> {
385    let mut seen_create_project = false;
386    let mut seen_json = false;
387
388    for arg in args {
389        match arg.as_str() {
390            "--create-project" => {
391                if std::mem::replace(&mut seen_create_project, true) {
392                    return Err(CliError::DuplicateFlag {
393                        flag: "--create-project",
394                        next: "use --create-project once",
395                    });
396                }
397            }
398            "--json" => {
399                if std::mem::replace(&mut seen_json, true) {
400                    return Err(CliError::DuplicateFlag {
401                        flag: "--json",
402                        next: "use --json once",
403                    });
404                }
405            }
406            "--help" | "-h" => {}
407            flag if flag.starts_with('-') => {
408                return Err(unknown_flag(flag, PROJECTS_NEXT_STEP));
409            }
410            argument => {
411                return Err(CliError::UnexpectedArgument {
412                    argument: argument.to_owned(),
413                    command: "setup",
414                    next: PROJECTS_NEXT_STEP,
415                });
416            }
417        }
418    }
419
420    Ok(Command::Help {
421        topic: HelpTopic::Projects,
422        json: seen_json,
423    })
424}
425
426/// Parses backend-owned project commands.
427fn parse_project(args: &[String]) -> Result<Command, CliError> {
428    let normalized = move_leading_json_to_tail(args);
429    if let Some((subcommand, tail)) = normalized.split_first()
430        && subcommand == "setup"
431        && has_position_candidate(tail)
432    {
433        return parse_project_setup_seen(tail);
434    }
435    parse_discovery_help(HelpTopic::Projects, args)
436}
437
438/// Parses `projects setup <project_id>`.
439fn parse_project_setup_seen(args: &[String]) -> Result<Command, CliError> {
440    let (project_id, tail) =
441        take_required_position(args, "project_id", PROJECT_SETUP_SEEN_NEXT_STEP)?;
442    let (options, json) = parse_project_setup_seen_flags(tail.as_slice())?;
443    Ok(Command::ProjectSetupSeen {
444        project_id,
445        options,
446        json,
447    })
448}
449
450/// Parses flags for backend setup seen calls.
451fn parse_project_setup_seen_flags(
452    args: &[String],
453) -> Result<(ProjectSetupSeenOptions, bool), CliError> {
454    let mut options = ProjectSetupSeenOptions::default();
455    let mut json = false;
456    let mut seen = Vec::new();
457    let mut index = 0;
458
459    while let Some(arg) = args.get(index) {
460        let (flag, inline_value) = split_project_setup_seen_inline_value(arg.as_str());
461        match flag {
462            "--json" if inline_value.is_none() => {
463                mark_project_setup_seen_flag(&mut seen, "--json")?;
464                json = true;
465            }
466            "--runtime" => {
467                mark_project_setup_seen_flag(&mut seen, "--runtime")?;
468                options.runtime = Some(project_setup_seen_flag_value(
469                    args,
470                    &mut index,
471                    "--runtime",
472                    inline_value,
473                )?);
474            }
475            "--source" => {
476                mark_project_setup_seen_flag(&mut seen, "--source")?;
477                options.source = Some(validate_project_setup_seen_source(
478                    project_setup_seen_flag_value(args, &mut index, "--source", inline_value)?
479                        .as_str(),
480                )?);
481            }
482            "--environment" | "--env" => {
483                mark_project_setup_seen_flag(&mut seen, "--environment")?;
484                let visible_flag = if flag == "--env" {
485                    "--env"
486                } else {
487                    "--environment"
488                };
489                options.environment = Some(project_setup_seen_flag_value(
490                    args,
491                    &mut index,
492                    visible_flag,
493                    inline_value,
494                )?);
495            }
496            flag if flag.starts_with('-') => {
497                return Err(unknown_flag(flag, PROJECT_SETUP_SEEN_NEXT_STEP));
498            }
499            argument => {
500                return Err(CliError::UnexpectedArgument {
501                    argument: argument.to_owned(),
502                    command: "projects setup",
503                    next: PROJECT_SETUP_SEEN_NEXT_STEP,
504                });
505            }
506        }
507        index += 1;
508    }
509
510    Ok((options, json))
511}
512
513/// Splits a value-taking project setup flag.
514fn split_project_setup_seen_inline_value(flag: &str) -> (&str, Option<&str>) {
515    flag.split_once('=')
516        .map_or((flag, None), |(name, value)| (name, Some(value)))
517}
518
519/// Records a project setup flag and rejects duplicate occurrences.
520fn mark_project_setup_seen_flag(
521    seen: &mut Vec<&'static str>,
522    flag: &'static str,
523) -> Result<(), CliError> {
524    if seen.contains(&flag) {
525        return Err(CliError::DuplicateFlag {
526            flag,
527            next: project_setup_seen_duplicate_next(flag),
528        });
529    }
530    seen.push(flag);
531    Ok(())
532}
533
534/// Returns the recovery step for duplicate project setup flags.
535fn project_setup_seen_duplicate_next(flag: &'static str) -> &'static str {
536    match flag {
537        "--json" => "use --json once",
538        "--runtime" => "use --runtime once",
539        "--source" => "use --source once",
540        "--environment" => "use --environment once",
541        _ => "use the flag once",
542    }
543}
544
545/// Reads a value for a project setup flag.
546fn project_setup_seen_flag_value(
547    args: &[String],
548    index: &mut usize,
549    flag: &'static str,
550    inline_value: Option<&str>,
551) -> Result<String, CliError> {
552    if let Some(value) = inline_value {
553        if value.is_empty() {
554            return Err(missing_project_setup_seen_flag_value(flag));
555        }
556        return Ok(value.to_owned());
557    }
558    *index += 1;
559    let Some(value) = args.get(*index) else {
560        return Err(missing_project_setup_seen_flag_value(flag));
561    };
562    if value.starts_with('-') {
563        return Err(missing_project_setup_seen_flag_value(flag));
564    }
565    Ok(value.clone())
566}
567
568/// Builds a missing-value error for project setup flags.
569fn missing_project_setup_seen_flag_value(flag: &'static str) -> CliError {
570    CliError::MissingFlagValue {
571        flag,
572        next: project_setup_seen_missing_value_next(flag),
573    }
574}
575
576/// Returns the recovery step for missing project setup flag values.
577fn project_setup_seen_missing_value_next(flag: &'static str) -> &'static str {
578    match flag {
579        "--runtime" => "provide a value after --runtime",
580        "--source" => PROJECT_SETUP_SOURCE_NEXT_STEP,
581        "--environment" => "provide a value after --environment",
582        "--env" => "provide a value after --env",
583        _ => "provide a value after the flag",
584    }
585}
586
587/// Validates setup source values accepted by the public backend contract.
588fn validate_project_setup_seen_source(source: &str) -> Result<String, CliError> {
589    match source {
590        "api" | "cli" | "sdk" => Ok(source.to_owned()),
591        other => Err(CliError::InvalidSetupSource(other.to_owned())),
592    }
593}
594
595/// Parses `status`.
596fn parse_status(args: &[String]) -> Result<Command, CliError> {
597    let flags = parse_flags(args, FlagScope::Status)?;
598    Ok(Command::Status {
599        json: flags.is_json(),
600    })
601}
602
603/// Parses `version`.
604fn parse_version(args: &[String]) -> Result<Command, CliError> {
605    let flags = parse_flags(args, FlagScope::Version)?;
606    Ok(Command::Version {
607        json: flags.is_json(),
608    })
609}
610
611/// Takes one required positional argument and rejects flags in its place.
612fn take_required_arg<'a>(
613    args: &'a [String],
614    argument: &'static str,
615    next: &'static str,
616) -> Result<(&'a str, &'a [String]), CliError> {
617    let Some((value, rest)) = args.split_first() else {
618        return Err(CliError::MissingArgument { argument, next });
619    };
620    if value.starts_with('-') {
621        return Err(CliError::MissingArgument { argument, next });
622    }
623    Ok((value.as_str(), rest))
624}
625
626/// Moves a leading JSON flag behind required positional arguments.
627fn move_leading_json_to_tail(args: &[String]) -> Vec<String> {
628    if args.first().is_some_and(|arg| arg == "--json") {
629        let mut normalized = Vec::with_capacity(args.len());
630        normalized.extend(args[1..].iter().cloned());
631        normalized.push(String::from("--json"));
632        normalized
633    } else {
634        args.to_vec()
635    }
636}
637
638/// Returns whether a command has a required positional candidate after `--json`.
639fn has_position_candidate(args: &[String]) -> bool {
640    move_leading_json_to_tail(args)
641        .first()
642        .is_some_and(|arg| !arg.starts_with('-'))
643}
644
645/// Takes a required positional argument after tolerating a leading JSON flag.
646fn take_required_position(
647    args: &[String],
648    argument: &'static str,
649    next: &'static str,
650) -> Result<(String, Vec<String>), CliError> {
651    let normalized = move_leading_json_to_tail(args);
652    let (value, rest) = take_required_arg(normalized.as_slice(), argument, next)?;
653    Ok((value.to_owned(), rest.to_vec()))
654}
655
656/// Parses `read`.
657fn parse_read(args: &[String]) -> Result<Command, CliError> {
658    let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
659    let resource = normalize_read_resource(resource.as_str());
660    if is_recency_read_verb(resource) {
661        return parse_read_verb(resource, rest.as_slice());
662    }
663    if is_known_issue_status(resource) && has_status_first_issue_resource_candidate(rest.as_slice())
664    {
665        return parse_status_first_issue_read(resource, rest.as_slice());
666    }
667    parse_read_resource(resource, rest.as_slice())
668}
669
670/// Normalizes safe singular collection words behind `read`.
671fn normalize_read_resource(resource: &str) -> &str {
672    match resource {
673        "log" => "logs",
674        "release" => "releases",
675        _ => resource,
676    }
677}
678
679/// Parses natural read-only verbs such as `show logs`.
680fn parse_read_verb(verb: &str, args: &[String]) -> Result<Command, CliError> {
681    let rewritten_args = recency_count_shortcut_args(verb, args);
682    let args = rewritten_args.as_deref().unwrap_or(args);
683    let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
684    if is_known_issue_status(resource.as_str())
685        && has_status_first_issue_resource_candidate(rest.as_slice())
686    {
687        return parse_status_first_issue_read(resource.as_str(), rest.as_slice());
688    }
689    let resource = normalize_read_verb_resource(verb, resource.as_str());
690    parse_read_resource(resource, rest.as_slice())
691}
692
693/// Rewrites `last 10 logs` to `last logs --limit 10`.
694fn recency_count_shortcut_args(verb: &str, args: &[String]) -> Option<Vec<String>> {
695    if !is_recency_read_verb(verb) {
696        return None;
697    }
698    let normalized = move_leading_json_to_tail(args);
699    let (count, tail) = normalized.split_first().filter(|(count, tail)| {
700        !tail.is_empty() && count.chars().all(|char| char.is_ascii_digit())
701    })?;
702    let mut rewritten = Vec::with_capacity(normalized.len() + 2);
703    rewritten.push(tail[0].clone());
704    let rest = &tail[1..];
705    if let Some(separator_index) = rest.iter().position(|arg| arg == "--") {
706        rewritten.extend(rest[..separator_index].iter().cloned());
707        rewritten.push(String::from("--limit"));
708        rewritten.push(count.clone());
709        rewritten.extend(rest[separator_index..].iter().cloned());
710        return Some(rewritten);
711    }
712    rewritten.extend(rest.iter().cloned());
713    rewritten.push(String::from("--limit"));
714    rewritten.push(count.clone());
715    Some(rewritten)
716}
717
718/// Returns whether a command is a natural read-only verb.
719fn is_read_verb(value: &str) -> bool {
720    matches!(value, "show" | "list" | "get") || is_recency_read_verb(value)
721}
722
723/// Returns whether a command is a recency-flavored read alias.
724fn is_recency_read_verb(value: &str) -> bool {
725    matches!(value, "latest" | "recent" | "last" | "newest")
726}
727
728/// Normalizes singular collection words behind natural read verbs.
729fn normalize_read_verb_resource<'a>(verb: &str, resource: &'a str) -> &'a str {
730    match (verb, resource) {
731        ("list" | "show" | "get", "log") => "logs",
732        (alias, "log") if is_recency_read_verb(alias) => "logs",
733        (alias, "issue") if is_recency_read_verb(alias) => "issues",
734        ("list", "issue") => "issues",
735        ("list" | "show" | "get", "release") => "releases",
736        (alias, "release") if is_recency_read_verb(alias) => "releases",
737        _ => resource,
738    }
739}
740
741/// Returns whether a command is a natural log search shortcut.
742fn is_log_search_shortcut(command: &str) -> bool {
743    matches!(command, "search" | "find" | "grep")
744}
745
746/// Returns whether a log search form uses `--` to search help-looking text.
747fn is_log_search_separator_literal(command: &str, args: &[String]) -> bool {
748    literal_log_search_separator_index(command, args).is_some()
749}
750
751/// Returns the static argument label for a natural log search shortcut.
752fn log_search_shortcut_label(command: &str) -> &'static str {
753    match command {
754        "find" => "find",
755        "grep" => "grep",
756        _ => "search",
757    }
758}
759
760/// Parses natural log search shortcuts as `logs --search <text>`.
761fn parse_search_shortcut(label: &'static str, args: &[String]) -> Result<Command, CliError> {
762    let (query, tail) = take_search_query(args, label)?;
763    let mut rest = Vec::with_capacity(tail.len() + 2);
764    if query.starts_with('-') {
765        rest.push(format!("--search={query}"));
766    } else {
767        rest.push(String::from("--search"));
768        rest.push(query);
769    }
770    rest.extend(tail);
771    parse_read_resource("logs", rest.as_slice())
772}
773
774/// Takes leading search text, allowing unquoted multi-word query shortcuts.
775fn take_search_query(
776    args: &[String],
777    argument: &'static str,
778) -> Result<(String, Vec<String>), CliError> {
779    let normalized = move_leading_json_to_tail(args);
780    if normalized.first().is_some_and(|arg| arg == "--") {
781        return take_separator_search_query(normalized.as_slice(), argument);
782    }
783    let query_word_count = normalized
784        .iter()
785        .take_while(|arg| !arg.starts_with('-'))
786        .count();
787    if query_word_count == 0 {
788        return Err(CliError::MissingArgument {
789            argument,
790            next: SEARCH_NEXT_STEP,
791        });
792    }
793    let query = normalized[..query_word_count].join(" ");
794    let tail = normalized[query_word_count..].to_vec();
795    Ok((query, tail))
796}
797
798/// Takes search text after `--`, allowing literal flag-looking terms.
799fn take_separator_search_query(
800    args: &[String],
801    argument: &'static str,
802) -> Result<(String, Vec<String>), CliError> {
803    let words = &args[1..];
804    if words.is_empty() {
805        return Err(CliError::MissingArgument {
806            argument,
807            next: SEARCH_NEXT_STEP,
808        });
809    }
810    let has_trailing_json_mode = words.len() > 1 && words.last().is_some_and(|arg| arg == "--json");
811    let query_end = if has_trailing_json_mode {
812        words.len() - 1
813    } else {
814        words.len()
815    };
816    let query = words[..query_end].join(" ");
817    let tail = if has_trailing_json_mode {
818        vec![String::from("--json")]
819    } else {
820        Vec::new()
821    };
822    Ok((query, tail))
823}
824
825/// Parses `read` resource arguments or top-level read shortcuts.
826fn parse_read_resource(resource: &str, rest: &[String]) -> Result<Command, CliError> {
827    let (target, flags) = match resource {
828        "logs" => parse_log_list_read(rest)?,
829        alias if is_issue_collection_alias(alias) && has_issue_id_candidate(rest) => {
830            return parse_issue_detail_or_status(rest);
831        }
832        alias if is_issue_collection_alias(alias) => parse_issue_list_read(rest)?,
833        alias if is_action_collection_alias(alias) => parse_action_list_read(rest)?,
834        "releases" => parse_list_read(
835            ReadTarget::Releases,
836            rest,
837            "read releases",
838            READ_RELEASES_NEXT_STEP,
839            &[
840                "--name",
841                "--since",
842                "--user",
843                "--distinct-id",
844                "--trace",
845                "--trace-id",
846                "--level",
847                "--severity",
848                "--search",
849                "--status",
850            ],
851        )?,
852        "trace" => return parse_trace_detail_or_explain(rest),
853        "traces" | "span" | "spans" if has_position_candidate(rest) => {
854            return parse_trace_detail_or_explain(rest);
855        }
856        "issue" if has_issue_status_candidate(rest) => parse_issue_list_read(rest)?,
857        "issue" => return parse_issue_detail_or_status(rest),
858        other => return Err(unknown_read_resource(other)),
859    };
860    let json = flags.is_json();
861    let options = flags.into_read_options();
862    validate_read_filters(&target, &options)?;
863
864    Ok(Command::Read {
865        target,
866        options: Box::new(options),
867        json,
868    })
869}
870
871/// Returns whether a resource word is an issue list alias.
872fn is_issue_collection_alias(value: &str) -> bool {
873    matches!(
874        value,
875        "issues" | "errors" | "error" | "exceptions" | "exception"
876    )
877}
878
879/// Returns whether a resource word can follow a status-first issue shortcut.
880fn is_status_first_issue_collection_alias(value: &str) -> bool {
881    value == "issue" || is_issue_collection_alias(value)
882}
883
884/// Returns whether args begin with an issue collection after a status word.
885fn has_status_first_issue_resource_candidate(args: &[String]) -> bool {
886    move_leading_json_to_tail(args)
887        .first()
888        .is_some_and(|arg| is_status_first_issue_collection_alias(arg))
889}
890
891/// Returns whether args begin with an issue status after optional `--json`.
892fn has_issue_status_candidate(args: &[String]) -> bool {
893    move_leading_json_to_tail(args)
894        .first()
895        .is_some_and(|arg| is_known_issue_status(arg))
896}
897
898/// Returns whether a resource word is an action list alias.
899fn is_action_collection_alias(value: &str) -> bool {
900    matches!(value, "actions" | "events" | "event" | "action")
901}
902
903/// Parses log lists, accepting natural search and positional severity aliases.
904fn parse_log_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
905    let args = log_shortcut_args(rest);
906    parse_list_read(
907        ReadTarget::Logs,
908        args.as_slice(),
909        "read logs",
910        READ_LOGS_NEXT_STEP,
911        &["--name", "--user", "--distinct-id", "--status"],
912    )
913}
914
915/// Parses issue/error lists, accepting a first positional status word.
916fn parse_issue_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
917    let args = issue_status_shortcut_args(rest);
918    parse_list_read(
919        ReadTarget::Issues,
920        args.as_slice(),
921        "read issues",
922        READ_ISSUES_NEXT_STEP,
923        &[
924            "--name",
925            "--since",
926            "--user",
927            "--distinct-id",
928            "--trace",
929            "--trace-id",
930            "--level",
931            "--severity",
932            "--search",
933        ],
934    )
935}
936
937/// Parses `open issues` as `issues --status unresolved`.
938fn parse_status_first_issue_read(status: &str, args: &[String]) -> Result<Command, CliError> {
939    let canonical_status = normalize_status(status)?;
940    let (resource, rest) = take_required_position(args, "resource", READ_ISSUES_NEXT_STEP)?;
941    let resource = resource.as_str();
942    if !is_status_first_issue_collection_alias(resource) {
943        return Err(unknown_read_resource(resource));
944    }
945    let resource = if resource == "issue" {
946        "issues"
947    } else {
948        resource
949    };
950    let mut rewritten = Vec::with_capacity(rest.len() + 2);
951    rewritten.push(String::from("--status"));
952    rewritten.push(canonical_status);
953    rewritten.extend(rest);
954    parse_read_resource(resource, rewritten.as_slice())
955}
956
957/// Rewrites `issues open` to `issues --status unresolved`.
958fn issue_status_shortcut_args(args: &[String]) -> Vec<String> {
959    let normalized = move_leading_json_to_tail(args);
960    let Some((status, tail)) = normalized
961        .split_first()
962        .and_then(|(status, tail)| normalize_status(status).ok().map(|value| (value, tail)))
963    else {
964        return args.to_vec();
965    };
966    let mut rewritten = Vec::with_capacity(normalized.len() + 2);
967    rewritten.push(String::from("--status"));
968    rewritten.push(status);
969    rewritten.extend(tail.iter().cloned());
970    rewritten
971}
972
973/// Parses action/event lists, accepting a first positional as `--name`.
974fn parse_action_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
975    let args = action_name_shortcut_args(rest);
976    parse_list_read(
977        ReadTarget::Actions,
978        args.as_slice(),
979        "read actions",
980        READ_ACTIONS_NEXT_STEP,
981        ACTION_LIST_UNSUPPORTED_FLAGS,
982    )
983}
984
985/// Rewrites `events checkout_failed` to `actions --name checkout_failed`.
986fn action_name_shortcut_args(args: &[String]) -> Vec<String> {
987    let normalized = move_leading_json_to_tail(args);
988    let Some((name, tail)) = normalized
989        .split_first()
990        .filter(|(name, _)| !name.starts_with('-') && !is_read_filter_word(name))
991    else {
992        return args.to_vec();
993    };
994    let mut rewritten = Vec::with_capacity(normalized.len() + 2);
995    rewritten.push(String::from("--name"));
996    rewritten.push(name.clone());
997    rewritten.extend(tail.iter().cloned());
998    rewritten
999}
1000
1001/// Returns whether args start with an obvious issue id after optional `--json`.
1002fn has_issue_id_candidate(args: &[String]) -> bool {
1003    move_leading_json_to_tail(args)
1004        .first()
1005        .is_some_and(|arg| is_issue_id(arg))
1006}
1007
1008/// Parses issue detail reads and issue-first mutation shortcuts.
1009fn parse_issue_detail_or_status(args: &[String]) -> Result<Command, CliError> {
1010    let (id, tail) = take_required_position(args, "issue_id", "provide an issue id")?;
1011    if has_issue_status_action(tail.as_slice()) {
1012        return parse_issue_first_status_shortcut(id, tail.as_slice());
1013    }
1014    if let Some(command) =
1015        parse_detail_explain_suffix(ExplainTarget::Issue(id.clone()), tail.as_slice())?
1016    {
1017        return Ok(command);
1018    }
1019    let target = ReadTarget::Issue(id);
1020    let flags = parse_detail_read_flags(
1021        tail.as_slice(),
1022        "read issue",
1023        READ_ISSUE_NEXT_STEP,
1024        ISSUE_DETAIL_UNSUPPORTED_FLAGS,
1025    )?;
1026    let json = flags.is_json();
1027    let options = flags.into_read_options();
1028    validate_read_filters(&target, &options)?;
1029
1030    Ok(Command::Read {
1031        target,
1032        options: Box::new(options),
1033        json,
1034    })
1035}
1036
1037/// Parses a list read after rejecting filters the target cannot apply.
1038fn parse_list_read(
1039    target: ReadTarget,
1040    args: &[String],
1041    command: &'static str,
1042    next: &'static str,
1043    unsupported_flags: &[&str],
1044) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1045    reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
1046    Ok((target, parse_flags(args, FlagScope::Read)?))
1047}
1048
1049/// Rejects target-inapplicable read filters before parsing values.
1050fn reject_unsupported_read_flags(
1051    args: &[String],
1052    command: &'static str,
1053    next: &'static str,
1054    unsupported_flags: &[&str],
1055) -> Result<(), CliError> {
1056    let mut index = 0;
1057    let mut seen = Vec::new();
1058    while let Some(arg) = args.get(index) {
1059        let (flag, inline_value) = arg
1060            .split_once('=')
1061            .map_or((arg.as_str(), None), |(name, value)| (name, Some(value)));
1062        if !is_read_value_flag(flag) {
1063            if flag == "--json" && inline_value.is_none() {
1064                if seen.contains(&"--json") {
1065                    return Ok(());
1066                }
1067                seen.push("--json");
1068                index += 1;
1069                continue;
1070            }
1071            if inline_value.is_some() && is_simple_flag(flag) {
1072                return Err(CliError::UnsupportedFlag {
1073                    flag: arg.to_owned(),
1074                    command,
1075                    next,
1076                });
1077            }
1078            if arg.starts_with('-') {
1079                return Err(unknown_flag(arg, next));
1080            }
1081            return Ok(());
1082        }
1083        if unsupported_flags.contains(&flag) {
1084            return Err(CliError::UnsupportedFlag {
1085                flag: user_facing_read_flag(flag).to_owned(),
1086                command,
1087                next,
1088            });
1089        }
1090        if let Some(canonical) = read_value_canonical_flag(flag) {
1091            if seen.contains(&canonical) {
1092                return Ok(());
1093            }
1094            seen.push(canonical);
1095        }
1096        if inline_value.is_some_and(str::is_empty) {
1097            return Ok(());
1098        }
1099        if inline_value.is_some_and(|value| has_invalid_supported_read_value(flag, value)) {
1100            return Ok(());
1101        }
1102        if inline_value.is_none() {
1103            let Some(value) = args.get(index + 1) else {
1104                return Ok(());
1105            };
1106            if value.starts_with('-') {
1107                return Ok(());
1108            }
1109            if has_invalid_supported_read_value(flag, value) {
1110                return Ok(());
1111            }
1112            index += 1;
1113        }
1114        index += 1;
1115    }
1116    Ok(())
1117}
1118
1119/// Returns the duplicate-tracking key for a read value flag.
1120fn read_value_canonical_flag(flag: &str) -> Option<&'static str> {
1121    let canonical = match flag {
1122        "--name" => "--name",
1123        "--since" => "--since",
1124        "--user" | "--distinct-id" => "--user",
1125        "--trace" | "--trace-id" => "--trace",
1126        "--level" | "--severity" => "--severity",
1127        "--search" => "--search",
1128        "--project" | "--project-id" => "--project",
1129        "--release" => "--release",
1130        "--environment" | "--env" => "--environment",
1131        "--status" => "--status",
1132        "--limit" => "--limit",
1133        _ => return None,
1134    };
1135    Some(canonical)
1136}
1137
1138/// Returns the canonical flag name to show in read-filter recovery output.
1139fn user_facing_read_flag(flag: &str) -> &str {
1140    match flag {
1141        "--level" => "--severity",
1142        other => other,
1143    }
1144}
1145
1146/// Returns whether a supported read flag has a value that should be reported first.
1147fn has_invalid_supported_read_value(flag: &str, value: &str) -> bool {
1148    match flag {
1149        "--level" | "--severity" => !is_known_log_level(value),
1150        "--status" => !is_known_issue_status(value),
1151        "--limit" => value.parse::<u32>().map_or(true, |limit| limit == 0),
1152        _ => false,
1153    }
1154}
1155
1156/// Returns whether a value is in the log-level vocabulary.
1157fn is_known_log_level(value: &str) -> bool {
1158    normalize_log_level(value).is_ok()
1159}
1160
1161/// Returns whether a positional log search word should stay a recoverable error.
1162fn is_ambiguous_log_search_word(value: &str) -> bool {
1163    is_read_filter_word(value) || value.contains('@') || is_trace_id(value)
1164}
1165
1166/// Returns whether a value is in the issue-status vocabulary.
1167fn is_known_issue_status(value: &str) -> bool {
1168    normalize_status(value).is_ok()
1169}
1170
1171/// Returns whether a flag is a value-taking read filter.
1172fn is_read_value_flag(flag: &str) -> bool {
1173    matches!(
1174        flag,
1175        "--name"
1176            | "--since"
1177            | "--user"
1178            | "--distinct-id"
1179            | "--trace"
1180            | "--trace-id"
1181            | "--level"
1182            | "--severity"
1183            | "--search"
1184            | "--project"
1185            | "--project-id"
1186            | "--release"
1187            | "--environment"
1188            | "--env"
1189            | "--status"
1190            | "--limit"
1191    )
1192}
1193
1194/// Parses one trace detail read or trace explain suffix.
1195fn parse_trace_detail_or_explain(rest: &[String]) -> Result<Command, CliError> {
1196    let (id, tail) = take_required_position(rest, "trace_id", "provide a trace id")?;
1197    if let Some(command) =
1198        parse_detail_explain_suffix(ExplainTarget::Trace(id.clone()), tail.as_slice())?
1199    {
1200        return Ok(command);
1201    }
1202    let target = ReadTarget::Trace(id);
1203    let flags = parse_detail_read_flags(
1204        tail.as_slice(),
1205        "read trace",
1206        READ_TRACE_NEXT_STEP,
1207        TRACE_DETAIL_UNSUPPORTED_FLAGS,
1208    )?;
1209    let json = flags.is_json();
1210    let options = flags.into_read_options();
1211    validate_read_filters(&target, &options)?;
1212
1213    Ok(Command::Read {
1214        target,
1215        options: Box::new(options),
1216        json,
1217    })
1218}
1219
1220/// Parses a trailing `explain` action after an issue or trace detail id.
1221fn parse_detail_explain_suffix(
1222    target: ExplainTarget,
1223    args: &[String],
1224) -> Result<Option<Command>, CliError> {
1225    let normalized = move_leading_json_to_tail(args);
1226    if normalized.first().is_none_or(|arg| arg != "explain") {
1227        return Ok(None);
1228    }
1229    Ok(Some(Command::Explain {
1230        target,
1231        json: parse_flags(&normalized[1..], FlagScope::Explain)?.is_json(),
1232    }))
1233}
1234
1235/// Parses detail read filters after rejecting list-only filters.
1236fn parse_detail_read_flags(
1237    args: &[String],
1238    command: &'static str,
1239    next: &'static str,
1240    unsupported_flags: &[&str],
1241) -> Result<crate::flags::Flags, CliError> {
1242    reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
1243    parse_flags(args, FlagScope::Read)
1244}
1245
1246/// Parses an obvious pasted issue or trace id as a detail read shortcut.
1247fn parse_pasted_detail_id(id: &str, args: &[String]) -> Result<Command, CliError> {
1248    if is_issue_id(id) && has_issue_status_action(args) {
1249        return parse_issue_first_status_shortcut(id.to_owned(), args);
1250    }
1251    let explain_args = move_leading_json_to_tail(args);
1252    if explain_args.first().is_some_and(|arg| arg == "explain") {
1253        let target = infer_explain_target(id).ok_or_else(|| unknown_command(id))?;
1254        return Ok(Command::Explain {
1255            target,
1256            json: parse_flags(&explain_args[1..], FlagScope::Explain)?.is_json(),
1257        });
1258    }
1259    let (target, flags) = if is_trace_id(id) {
1260        (
1261            ReadTarget::Trace(id.to_owned()),
1262            parse_detail_read_flags(
1263                args,
1264                "read trace",
1265                READ_TRACE_NEXT_STEP,
1266                TRACE_DETAIL_UNSUPPORTED_FLAGS,
1267            )?,
1268        )
1269    } else if is_issue_id(id) {
1270        (
1271            ReadTarget::Issue(id.to_owned()),
1272            parse_detail_read_flags(
1273                args,
1274                "read issue",
1275                READ_ISSUE_NEXT_STEP,
1276                ISSUE_DETAIL_UNSUPPORTED_FLAGS,
1277            )?,
1278        )
1279    } else {
1280        return Err(unknown_command(id));
1281    };
1282    let json = flags.is_json();
1283    let options = flags.into_read_options();
1284    validate_read_filters(&target, &options)?;
1285
1286    Ok(Command::Read {
1287        target,
1288        options: Box::new(options),
1289        json,
1290    })
1291}
1292
1293/// Rejects filters that a read endpoint would otherwise ignore.
1294fn validate_read_filters(target: &ReadTarget, filters: &ReadOptions) -> Result<(), CliError> {
1295    let unsupported = match target {
1296        ReadTarget::Logs => filters
1297            .first_log_unsupported_flag()
1298            .map(|flag| (flag, "read logs", READ_LOGS_NEXT_STEP)),
1299        ReadTarget::Issues => filters
1300            .first_issue_list_unsupported_flag()
1301            .map(|flag| (flag, "read issues", READ_ISSUES_NEXT_STEP)),
1302        ReadTarget::Actions => filters
1303            .first_action_unsupported_flag()
1304            .map(|flag| (flag, "read actions", READ_ACTIONS_NEXT_STEP)),
1305        ReadTarget::Releases => filters
1306            .first_release_unsupported_flag()
1307            .map(|flag| (flag, "read releases", READ_RELEASES_NEXT_STEP)),
1308        ReadTarget::Trace(_) => filters
1309            .first_trace_detail_unsupported_flag()
1310            .map(|flag| (flag, "read trace", READ_TRACE_NEXT_STEP)),
1311        ReadTarget::Issue(_) => filters
1312            .first_issue_detail_unsupported_flag()
1313            .map(|flag| (flag, "read issue", READ_ISSUE_NEXT_STEP)),
1314    };
1315
1316    if let Some((flag, command, next)) = unsupported {
1317        return Err(CliError::UnsupportedFlag {
1318            flag: flag.to_owned(),
1319            command,
1320            next,
1321        });
1322    }
1323    Ok(())
1324}
1325
1326/// Parses `explain`.
1327fn parse_explain(args: &[String]) -> Result<Command, CliError> {
1328    let (resource, rest) = take_required_position(args, "resource", EXPLAIN_RESOURCE_NEXT_STEP)?;
1329    let (target, tail) = match resource.as_str() {
1330        "issue" => {
1331            let (id, tail) =
1332                take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1333            (ExplainTarget::Issue(id), tail)
1334        }
1335        "trace" => {
1336            let (id, tail) =
1337                take_required_position(rest.as_slice(), "trace_id", "provide a trace id")?;
1338            (ExplainTarget::Trace(id), tail)
1339        }
1340        other => {
1341            if let Some(target) = infer_explain_target(other) {
1342                (target, rest)
1343            } else {
1344                return Err(unknown_resource(other, EXPLAIN_RESOURCE_NEXT_STEP));
1345            }
1346        }
1347    };
1348    let flags = parse_flags(tail.as_slice(), FlagScope::Explain)?;
1349    Ok(Command::Explain {
1350        target,
1351        json: flags.is_json(),
1352    })
1353}
1354
1355/// Parses `set`.
1356fn parse_set(args: &[String]) -> Result<Command, CliError> {
1357    let (resource, rest) = take_required_position(args, "resource", SET_RESOURCE_NEXT_STEP)?;
1358    if resource != "issue" {
1359        return Err(unknown_resource(resource.as_str(), SET_RESOURCE_NEXT_STEP));
1360    }
1361    let (id, rest) = take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1362    let (status, tail) =
1363        take_required_position(rest.as_slice(), "status", ISSUE_STATUS_ARGUMENT_NEXT_STEP)?;
1364    let status = normalize_status(status.as_str())?;
1365    let flags = parse_flags(tail.as_slice(), FlagScope::Set)?;
1366
1367    Ok(Command::Set {
1368        target: SetTarget::IssueStatus { id, status },
1369        json: flags.is_json(),
1370    })
1371}