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