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