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