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