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