Skip to main content

logbrew_cli/
parser.rs

1//! CLI command grammar.
2
3mod help_topics;
4mod issue_shortcuts;
5mod log_shortcuts;
6mod support;
7mod trace_reads;
8mod watch;
9
10use help_topics::{
11    command_shaped_help_topic, contains_help_flag, ensure_no_help_positionals, help_command,
12    help_topic, is_direct_filter_help_alias, is_help_flag, parse_help, parse_help_alias,
13    parse_literal_help, positional_args, validate_help_flags,
14};
15use issue_shortcuts::{
16    has_issue_status_action, is_issue_status_action_alias, parse_bare_issue_status_shortcut,
17    parse_issue_first_status_shortcut, parse_issue_status_shortcut,
18    parse_status_first_issue_id_shortcut,
19};
20use log_shortcuts::{literal_log_search_separator_index, log_shortcut_args};
21use support::parse_support;
22use trace_reads::{parse_trace_detail_or_explain, parse_trace_list_read};
23use watch::parse_watch;
24
25use crate::flags::{
26    FlagScope, is_read_filter_word, is_simple_flag, normalize_log_level, normalize_status,
27    parse_flags, validate_min_duration,
28};
29use crate::ids::{infer_explain_target, is_issue_id, is_pasted_detail_id, is_trace_id};
30use crate::{
31    CliError, Command, ExplainTarget, HelpTopic, ISSUE_STATUS_ARGUMENT_NEXT_STEP,
32    ProjectCreateOptions, ProjectSetupSeenOptions, ReadOptions, ReadTarget, SetTarget,
33    auth_namespace,
34};
35
36/// Standard next step for malformed help invocations.
37const HELP_NEXT_STEP: &str = "run logbrew --help";
38/// Valid resources for historical reads.
39const READ_RESOURCE_NEXT_STEP: &str =
40    "choose one of logs, issues, actions, releases, traces, trace, issue";
41/// Recovery hint for users who type plural trace resources.
42const READ_TRACE_ALIAS_NEXT_STEP: &str =
43    "use singular trace with an id: logbrew read trace <trace_id>";
44/// Recovery hint for users who type trace terminology as a top-level command.
45const TRACE_COMMAND_NEXT_STEP: &str =
46    "use logbrew trace <trace_id> or logbrew explain trace <trace_id>";
47/// Help for trace detail reads.
48const READ_TRACE_NEXT_STEP: &str = "run logbrew read trace --help";
49/// Help for issue detail reads.
50const READ_ISSUE_NEXT_STEP: &str = "run logbrew read issue --help";
51/// Help for log list reads.
52const READ_LOGS_NEXT_STEP: &str = "run logbrew read logs --help";
53/// Recovery hint for natural log search shortcuts.
54const SEARCH_NEXT_STEP: &str = "provide search text or run logbrew logs --help";
55/// Help for issue list reads.
56const READ_ISSUES_NEXT_STEP: &str = "run logbrew read issues --help";
57/// Help for action list reads.
58const READ_ACTIONS_NEXT_STEP: &str = "run logbrew read actions --help";
59/// Help for release list reads.
60const READ_RELEASES_NEXT_STEP: &str = "run logbrew read releases --help";
61/// Help for recent trace discovery.
62const READ_TRACES_NEXT_STEP: &str = "run logbrew read traces --help";
63/// Help for backend-owned project setup discovery.
64const PROJECTS_NEXT_STEP: &str = "run logbrew projects --help";
65/// Help for backend-owned project setup seen calls.
66const PROJECT_SETUP_SEEN_NEXT_STEP: &str = "run logbrew projects setup <project_id> --help";
67/// Valid setup source values for setup seen calls.
68const PROJECT_SETUP_SOURCE_NEXT_STEP: &str = "use --source api, cli, or sdk";
69/// Valid resources for live watch.
70const WATCH_RESOURCE_NEXT_STEP: &str = "choose logs, issues, actions, or omit a resource";
71/// Valid resources for explain.
72const EXPLAIN_RESOURCE_NEXT_STEP: &str = "choose issue or trace";
73/// Valid resources for state mutation.
74const SET_RESOURCE_NEXT_STEP: &str = "choose issue";
75/// Filters trace detail reads cannot apply.
76const TRACE_DETAIL_UNSUPPORTED_FLAGS: &[&str] = &[
77    "--name",
78    "--service",
79    "--service-name",
80    "--since",
81    "--user",
82    "--distinct-id",
83    "--trace",
84    "--trace-id",
85    "--level",
86    "--severity",
87    "--search",
88    "--status",
89    "--limit",
90    "--min-duration-ms",
91];
92/// Filters issue detail reads cannot apply.
93const ISSUE_DETAIL_UNSUPPORTED_FLAGS: &[&str] = &[
94    "--name",
95    "--service",
96    "--service-name",
97    "--since",
98    "--user",
99    "--distinct-id",
100    "--trace",
101    "--trace-id",
102    "--level",
103    "--severity",
104    "--search",
105    "--project",
106    "--project-id",
107    "--release",
108    "--environment",
109    "--env",
110    "--status",
111    "--limit",
112    "--min-duration-ms",
113];
114/// Filters action list reads cannot apply.
115const ACTION_LIST_UNSUPPORTED_FLAGS: &[&str] = &[
116    "--trace",
117    "--trace-id",
118    "--level",
119    "--severity",
120    "--search",
121    "--status",
122    "--min-duration-ms",
123];
124
125/// # Errors
126/// Returns [`CliError`] if the command grammar is invalid.
127pub fn parse_command<I, S>(args: I) -> Result<Command, CliError>
128where
129    I: IntoIterator<Item = S>,
130    S: AsRef<str>,
131{
132    let values = args
133        .into_iter()
134        .map(|arg| arg.as_ref().to_owned())
135        .collect::<Vec<_>>();
136    parse_values(values.as_slice())
137}
138
139/// Parses a collected argument slice.
140fn parse_values(values: &[String]) -> Result<Command, CliError> {
141    let args = values.get(1..).ok_or(CliError::UnknownCommand)?;
142    let Some((head, tail)) = args.split_first() else {
143        return Ok(Command::Help {
144            topic: HelpTopic::Root,
145            json: false,
146        });
147    };
148    if is_help_flag(head) {
149        validate_help_flags(tail)?;
150        ensure_no_help_positionals(positional_args(tail).as_slice())?;
151        return Ok(help_command(HelpTopic::Root, tail));
152    }
153    if head == "--json" {
154        return parse_global_json(values, tail);
155    }
156    if is_version_flag(head) {
157        return parse_version(tail);
158    }
159    if head.starts_with('-') {
160        return Err(unknown_flag(head, HELP_NEXT_STEP));
161    }
162    if head == "help" {
163        return parse_help(tail);
164    }
165    if let Some(command) = parse_literal_help(head, tail)? {
166        return Ok(command);
167    }
168    if is_setup_alias(head) && tail.iter().any(|arg| arg == "--create-project") {
169        return parse_setup_create_project(tail);
170    }
171    if contains_help_flag(tail) && !is_log_search_separator_literal(head, tail) {
172        validate_help_flags(tail)?;
173        if let Some(topic) = command_shaped_help_topic(head, tail) {
174            return Ok(help_command(topic, tail));
175        }
176        return Ok(help_command(help_topic(head, tail)?, tail));
177    }
178    match head.as_str() {
179        "login" => parse_login(tail),
180        "logout" => parse_logout(tail),
181        alias if is_setup_alias(alias) => parse_setup(tail),
182        "status" | "whoami" | "me" | "health" | "ping" => parse_status(tail),
183        "doctor" => parse_doctor(tail),
184        "version" => parse_version(tail),
185        "account" if tail.first().is_some_and(|arg| arg == "usage") => parse_usage(&tail[1..]),
186        alias if auth_namespace::is_namespace(alias) => auth_namespace::parse(tail),
187        alias if auth_namespace::is_help_alias(alias) => parse_help_alias(HelpTopic::Auth, tail),
188        "json" | "output" => parse_help_alias(HelpTopic::Json, tail),
189        alias if is_examples_help_alias(alias) => parse_help_alias(HelpTopic::Examples, tail),
190        alias if is_project_help_alias(alias) => parse_project(tail),
191        "usage" => parse_usage(tail),
192        "support" => parse_support(tail),
193        "investigate" => parse_investigate(tail),
194        "debug-artifacts" => parse_native_debug_artifacts(tail),
195        alias if is_direct_filter_help_alias(alias) => parse_help_alias(HelpTopic::Read, tail),
196        "read" => parse_read(tail),
197        alias if is_read_verb(alias) => parse_read_verb(alias, tail),
198        status if is_known_issue_status(status) && has_issue_id_candidate(tail) => {
199            parse_status_first_issue_id_shortcut(status, tail)
200        }
201        status
202            if is_known_issue_status(status) && has_status_first_issue_resource_candidate(tail) =>
203        {
204            parse_status_first_issue_read(status, tail)
205        }
206        status if is_known_issue_status(status) => parse_bare_issue_status_shortcut(status, tail),
207        alias if is_log_search_shortcut(alias) => {
208            parse_search_shortcut(log_search_shortcut_label(alias), tail)
209        }
210        "log" => parse_read_resource("logs", tail),
211        "release" => parse_read_resource("releases", tail),
212        alias if matches!(alias, "trace" | "span") && !has_position_candidate(tail) => {
213            parse_help_alias(HelpTopic::ReadTrace, tail)
214        }
215        alias if matches!(alias, "traces" | "spans") && has_trace_id_candidate(tail) => {
216            parse_read_resource("trace", tail)
217        }
218        "traces" | "spans" => parse_read_resource("traces", tail),
219        "logs" | "issues" | "errors" | "error" | "exceptions" | "exception" | "actions"
220        | "events" | "event" | "action" | "releases" | "trace" | "issue" => {
221            parse_read_resource(head, tail)
222        }
223        "span" if has_position_candidate(tail) => parse_read_resource("trace", tail),
224        "resolve" | "close" | "ignore" | "reopen" => parse_issue_status_shortcut(head, tail),
225        alias if is_watch_command_alias(alias) => parse_watch(tail),
226        "explain" => parse_explain(tail),
227        "set" => parse_set(tail),
228        id if is_pasted_detail_id(id) => parse_pasted_detail_id(id, tail),
229        _ => Err(unknown_command(head)),
230    }
231}
232
233/// Parses the closed Apple native debug-artifact grammar.
234fn parse_native_debug_artifacts(args: &[String]) -> Result<Command, CliError> {
235    let normalized = move_leading_json_to_tail(args);
236    let Some((operation, tail)) = normalized.split_first() else {
237        return Err(CliError::InvalidNativeDebugCommand);
238    };
239    match operation.as_str() {
240        "upload" => parse_native_debug_upload(tail),
241        "lookup" => parse_native_debug_lookup(tail),
242        _ => Err(CliError::InvalidNativeDebugCommand),
243    }
244}
245
246/// Parses one artifact upload and normalizes its public request scope.
247fn parse_native_debug_upload(args: &[String]) -> Result<Command, CliError> {
248    let Some((path, flags)) = args.split_first() else {
249        return Err(CliError::InvalidNativeDebugCommand);
250    };
251    if path.is_empty() || path.chars().any(char::is_control) || path.starts_with('-') {
252        return Err(CliError::InvalidNativeDebugCommand);
253    }
254    let parsed = parse_native_debug_scope(flags, false)?;
255    Ok(Command::NativeDebugArtifacts {
256        target: crate::NativeDebugArtifactsTarget::Upload(crate::NativeDebugUploadOptions {
257            path: path.clone(),
258            project_id: parsed.project_id,
259            release: parsed.release,
260            environment: parsed.environment,
261            service: parsed.service,
262        }),
263        json: parsed.json,
264    })
265}
266
267/// Parses one exact artifact lookup.
268fn parse_native_debug_lookup(args: &[String]) -> Result<Command, CliError> {
269    let parsed = parse_native_debug_scope(args, true)?;
270    let image_uuid = parsed
271        .image_uuid
272        .filter(|value| is_canonical_lower_uuid(value))
273        .ok_or(CliError::InvalidNativeDebugIdentity)?;
274    let architecture = parsed
275        .architecture
276        .filter(|value| matches!(value.as_str(), "arm64" | "arm64e" | "x86_64"))
277        .ok_or(CliError::InvalidNativeDebugIdentity)?;
278    Ok(Command::NativeDebugArtifacts {
279        target: crate::NativeDebugArtifactsTarget::Lookup(crate::NativeDebugLookupOptions {
280            project_id: parsed.project_id,
281            release: parsed.release,
282            environment: parsed.environment,
283            service: parsed.service,
284            image_uuid,
285            architecture,
286        }),
287        json: parsed.json,
288    })
289}
290
291/// Duplicate-aware native debug-artifact flag accumulator.
292#[derive(Default)]
293struct NativeDebugScope {
294    /// Account-owned project UUID.
295    project_id: String,
296    /// Exact normalized release.
297    release: String,
298    /// Exact normalized environment.
299    environment: String,
300    /// Exact normalized service.
301    service: String,
302    /// Optional lookup image UUID.
303    image_uuid: Option<String>,
304    /// Optional lookup architecture.
305    architecture: Option<String>,
306    /// Machine-readable output selection.
307    json: bool,
308}
309
310/// Parses required scope flags without reflecting malformed values.
311fn parse_native_debug_scope(args: &[String], lookup: bool) -> Result<NativeDebugScope, CliError> {
312    let mut project_id = None;
313    let mut release = None;
314    let mut environment = None;
315    let mut service = None;
316    let mut image_uuid = None;
317    let mut architecture = None;
318    let mut json = false;
319    let mut index = 0;
320    while let Some(flag) = args.get(index) {
321        if flag == "--json" {
322            if json {
323                return Err(CliError::InvalidNativeDebugCommand);
324            }
325            json = true;
326            index += 1;
327            continue;
328        }
329        let destination = match flag.as_str() {
330            "--project" => &mut project_id,
331            "--release" => &mut release,
332            "--environment" => &mut environment,
333            "--service" => &mut service,
334            "--image-uuid" if lookup => &mut image_uuid,
335            "--architecture" if lookup => &mut architecture,
336            _ => return Err(CliError::InvalidNativeDebugCommand),
337        };
338        if destination.is_some() {
339            return Err(CliError::InvalidNativeDebugCommand);
340        }
341        let value = args
342            .get(index + 1)
343            .ok_or(CliError::InvalidNativeDebugCommand)?;
344        *destination = Some(value.clone());
345        index += 2;
346    }
347
348    let project_id = project_id
349        .filter(|value| is_canonical_lower_uuid(value))
350        .ok_or(CliError::InvalidNativeDebugCommand)?;
351    let release = normalize_native_scope(release).ok_or(CliError::InvalidNativeDebugCommand)?;
352    let environment =
353        normalize_native_scope(environment).ok_or(CliError::InvalidNativeDebugCommand)?;
354    let service = normalize_native_scope(service).ok_or(CliError::InvalidNativeDebugCommand)?;
355    if lookup != (image_uuid.is_some() && architecture.is_some()) {
356        return Err(CliError::InvalidNativeDebugCommand);
357    }
358    Ok(NativeDebugScope {
359        project_id,
360        release,
361        environment,
362        service,
363        image_uuid,
364        architecture,
365        json,
366    })
367}
368
369/// Trims and bounds one public native artifact scope string.
370fn normalize_native_scope(value: Option<String>) -> Option<String> {
371    let value = value?;
372    let trimmed = value.trim();
373    (!trimmed.is_empty() && trimmed.len() <= 256 && !trimmed.chars().any(char::is_control))
374        .then(|| trimmed.to_owned())
375}
376
377/// Restricts public UUID inputs to lowercase dashed canonical form.
378fn is_canonical_lower_uuid(value: &str) -> bool {
379    value.len() == 36
380        && value.bytes().enumerate().all(|(index, byte)| {
381            matches!(index, 8 | 13 | 18 | 23) && byte == b'-'
382                || !matches!(index, 8 | 13 | 18 | 23)
383                    && (byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
384        })
385}
386
387/// Parses the closed, read-only issue investigation grammar.
388fn parse_investigate(args: &[String]) -> Result<Command, CliError> {
389    let normalized = move_leading_json_to_tail(args);
390    match normalized.as_slice() {
391        [resource, issue_id]
392            if resource == "issue" && is_safe_investigation_issue_id(issue_id.as_str()) =>
393        {
394            Ok(Command::InvestigateIssue {
395                issue_id: issue_id.clone(),
396                json: false,
397            })
398        }
399        [resource, issue_id, json]
400            if resource == "issue"
401                && is_safe_investigation_issue_id(issue_id.as_str())
402                && json == "--json" =>
403        {
404            Ok(Command::InvestigateIssue {
405                issue_id: issue_id.clone(),
406                json: true,
407            })
408        }
409        _ => Err(CliError::InvalidInvestigationCommand),
410    }
411}
412
413/// Restricts investigation IDs to canonical lowercase dashed UUIDs.
414fn is_safe_investigation_issue_id(value: &str) -> bool {
415    value.len() == 36
416        && value.bytes().enumerate().all(|(index, byte)| {
417            matches!(index, 8 | 13 | 18 | 23) && byte == b'-'
418                || !matches!(index, 8 | 13 | 18 | 23)
419                    && (byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
420        })
421}
422
423/// Parses non-mutating discovery help for backend-owned future workflows.
424fn parse_discovery_help(topic: HelpTopic, args: &[String]) -> Result<Command, CliError> {
425    validate_help_flags(args)?;
426    Ok(Command::Help {
427        topic,
428        json: args.iter().any(|arg| arg == "--json"),
429    })
430}
431
432/// Parses a leading global `--json` flag.
433fn parse_global_json(values: &[String], tail: &[String]) -> Result<Command, CliError> {
434    if global_json_tail_has_duplicate(tail) {
435        return Err(CliError::DuplicateFlag {
436            flag: "--json",
437            next: "use --json once",
438        });
439    }
440    if tail.is_empty() {
441        return Ok(Command::Help {
442            topic: HelpTopic::Root,
443            json: true,
444        });
445    }
446
447    let mut normalized = Vec::with_capacity(values.len());
448    if let Some(program) = values.first() {
449        normalized.push(program.clone());
450    }
451    normalized.extend(tail.iter().cloned());
452    normalized.push(String::from("--json"));
453    parse_values(normalized.as_slice())
454}
455
456/// Returns whether a global JSON command also contains a JSON mode flag.
457fn global_json_tail_has_duplicate(tail: &[String]) -> bool {
458    if !tail.iter().any(|arg| arg == "--json") {
459        return false;
460    }
461    let Some((command, rest)) = tail.split_first() else {
462        return false;
463    };
464    let Some(separator_index) = literal_log_search_separator_index(command, rest) else {
465        return true;
466    };
467    rest[..separator_index].iter().any(|arg| arg == "--json")
468}
469/// Builds an unknown-resource error with command-specific recovery guidance.
470fn unknown_resource(resource: &str, next: &'static str) -> CliError {
471    CliError::UnknownResource {
472        resource: resource.to_owned(),
473        next,
474    }
475}
476
477/// Builds an unknown-flag error with command-specific recovery guidance.
478fn unknown_flag(flag: &str, next: &'static str) -> CliError {
479    CliError::UnknownFlag {
480        flag: flag.to_owned(),
481        next,
482    }
483}
484
485/// Builds an unknown read resource error with common-term recovery guidance.
486fn unknown_read_resource(resource: &str) -> CliError {
487    unknown_resource(resource, read_resource_next_step(resource))
488}
489
490/// Returns the next step for unsupported read resources.
491fn read_resource_next_step(resource: &str) -> &'static str {
492    match resource {
493        "trace" | "traces" | "span" | "spans" => READ_TRACE_ALIAS_NEXT_STEP,
494        _ => READ_RESOURCE_NEXT_STEP,
495    }
496}
497
498/// Builds an unknown-command error with typo recovery guidance when available.
499fn unknown_command(command: &str) -> CliError {
500    CliError::UnknownCommandName {
501        command: command.to_owned(),
502        next: unknown_command_next_step(command),
503    }
504}
505
506/// Returns a next step for common command typos.
507fn unknown_command_next_step(command: &str) -> &'static str {
508    match command {
509        "logg" | "lgs" => "did you mean logbrew logs?",
510        "action" | "event" | "events" => "did you mean logbrew actions?",
511        "releaze" | "rels" => "did you mean logbrew releases?",
512        "statuz" | "stats" => "did you mean logbrew status?",
513        "error" | "errors" | "exception" | "exceptions" => "did you mean logbrew issues?",
514        "trace" | "traces" | "span" | "spans" => TRACE_COMMAND_NEXT_STEP,
515        "env" | "environment" | "environments" => {
516            "use --environment <environment> with logs, issues, actions, releases, or traces"
517        }
518        alias if auth_namespace::is_help_alias(alias) => "run logbrew help auth",
519        _ => HELP_NEXT_STEP,
520    }
521}
522
523/// Returns whether a word should land on status/health help.
524fn is_status_help_alias(value: &str) -> bool {
525    matches!(value, "status" | "health" | "ping" | "doctor")
526}
527
528/// Returns whether a word should land on example-oriented help.
529fn is_examples_help_alias(value: &str) -> bool {
530    matches!(
531        value,
532        "example" | "examples" | "sample" | "samples" | "recipe" | "recipes"
533    )
534}
535
536/// Returns whether a word should run the non-mutating setup plan.
537fn is_setup_alias(value: &str) -> bool {
538    matches!(value, "setup" | "init" | "install" | "configure" | "sdk")
539}
540
541/// Returns whether a word should land on backend-owned project setup help.
542fn is_project_help_alias(value: &str) -> bool {
543    matches!(value, "project" | "projects")
544}
545
546/// Returns whether a word should use the live watch placeholder flow.
547fn is_watch_command_alias(value: &str) -> bool {
548    matches!(value, "watch" | "tail" | "follow" | "stream")
549}
550
551/// Returns whether a value is a version flag.
552fn is_version_flag(value: &str) -> bool {
553    matches!(value, "--version" | "-V")
554}
555
556/// Parses `login`.
557fn parse_login(args: &[String]) -> Result<Command, CliError> {
558    let flags = parse_flags(args, FlagScope::Login)?;
559    let json = flags.is_json();
560    Ok(Command::Login {
561        open_browser: flags.should_open_browser() && !json,
562        json,
563    })
564}
565
566/// Parses `logout`.
567fn parse_logout(args: &[String]) -> Result<Command, CliError> {
568    let flags = parse_flags(args, FlagScope::Logout)?;
569    Ok(Command::Logout {
570        json: flags.is_json(),
571    })
572}
573
574/// Parses `setup`.
575fn parse_setup(args: &[String]) -> Result<Command, CliError> {
576    if args.iter().any(|arg| arg == "--create-project") {
577        return parse_setup_create_project(args);
578    }
579    let flags = parse_flags(args, FlagScope::Setup)?;
580    Ok(Command::Setup {
581        auto: flags.is_auto(),
582        yes: flags.skip_prompts(),
583        json: flags.is_json(),
584    })
585}
586
587/// Parses the help-only backend project creation shape advertised by setup help.
588fn parse_setup_create_project(args: &[String]) -> Result<Command, CliError> {
589    let mut seen_create_project = false;
590    let mut seen_json = false;
591
592    for arg in args {
593        match arg.as_str() {
594            "--create-project" => {
595                if std::mem::replace(&mut seen_create_project, true) {
596                    return Err(CliError::DuplicateFlag {
597                        flag: "--create-project",
598                        next: "use --create-project once",
599                    });
600                }
601            }
602            "--json" => {
603                if std::mem::replace(&mut seen_json, true) {
604                    return Err(CliError::DuplicateFlag {
605                        flag: "--json",
606                        next: "use --json once",
607                    });
608                }
609            }
610            "--help" | "-h" => {}
611            flag if flag.starts_with('-') => {
612                return Err(unknown_flag(flag, PROJECTS_NEXT_STEP));
613            }
614            argument => {
615                return Err(CliError::UnexpectedArgument {
616                    argument: argument.to_owned(),
617                    command: "setup",
618                    next: PROJECTS_NEXT_STEP,
619                });
620            }
621        }
622    }
623
624    Ok(Command::Help {
625        topic: HelpTopic::Projects,
626        json: seen_json,
627    })
628}
629
630/// Parses backend-owned project commands.
631fn parse_project(args: &[String]) -> Result<Command, CliError> {
632    let normalized = move_leading_json_to_tail(args);
633    if let Some((subcommand, tail)) = normalized.split_first()
634        && subcommand == "create"
635    {
636        return parse_project_create(tail);
637    }
638    if let Some((subcommand, tail)) = normalized.split_first()
639        && subcommand == "setup"
640        && has_position_candidate(tail)
641    {
642        return parse_project_setup_seen(tail);
643    }
644    parse_discovery_help(HelpTopic::Projects, args)
645}
646
647/// Parses the closed secure project creation grammar.
648fn parse_project_create(args: &[String]) -> Result<Command, CliError> {
649    let Some((name, tail)) = args.split_first() else {
650        return Err(CliError::InvalidProjectCreateCommand);
651    };
652    if name.starts_with('-') {
653        return Err(CliError::InvalidProjectCreateCommand);
654    }
655    let name = bounded_project_create_value(name, 120, false)
656        .ok_or(CliError::InvalidProjectCreateCommand)?;
657    if name.starts_with('-') {
658        return Err(CliError::InvalidProjectCreateCommand);
659    }
660    let mut runtime = None;
661    let mut environment = None;
662    let mut ingest_key_file = None;
663    let mut abandon_retry = false;
664    let mut json = false;
665    let mut index = 0;
666
667    while let Some(argument) = tail.get(index) {
668        let (flag, inline_value) = argument
669            .split_once('=')
670            .map_or((argument.as_str(), None), |(flag, value)| {
671                (flag, Some(value))
672            });
673        match flag {
674            "--runtime" if runtime.is_none() => {
675                let value = project_create_flag_value(tail, &mut index, inline_value)?;
676                runtime = optional_project_create_value(value, 64)?;
677            }
678            "--environment" if environment.is_none() => {
679                let value = project_create_flag_value(tail, &mut index, inline_value)?;
680                environment = optional_project_create_value(value, 64)?;
681            }
682            "--ingest-key-file" if ingest_key_file.is_none() => {
683                let value = project_create_flag_value(tail, &mut index, inline_value)?;
684                let trimmed = value.trim();
685                if trimmed.is_empty()
686                    || trimmed.len() > 4096
687                    || trimmed.chars().any(char::is_control)
688                {
689                    return Err(CliError::InvalidProjectCreateCommand);
690                }
691                ingest_key_file = Some(trimmed.to_owned());
692            }
693            "--abandon-retry" if inline_value.is_none() && !abandon_retry => {
694                abandon_retry = true;
695            }
696            "--json" if inline_value.is_none() && !json => json = true,
697            _ => return Err(CliError::InvalidProjectCreateCommand),
698        }
699        index += 1;
700    }
701
702    let ingest_key_file = ingest_key_file.ok_or(CliError::InvalidProjectCreateCommand)?;
703    Ok(Command::ProjectCreate {
704        options: ProjectCreateOptions {
705            name,
706            runtime,
707            environment,
708            ingest_key_file,
709            abandon_retry,
710        },
711        json,
712    })
713}
714
715/// Takes an inline or following project-create flag value without reflection.
716fn project_create_flag_value<'a>(
717    args: &'a [String],
718    index: &mut usize,
719    inline: Option<&'a str>,
720) -> Result<&'a str, CliError> {
721    if let Some(value) = inline {
722        return Ok(value);
723    }
724    *index += 1;
725    args.get(*index)
726        .map(String::as_str)
727        .filter(|value| !value.starts_with('-'))
728        .ok_or(CliError::InvalidProjectCreateCommand)
729}
730
731/// Trims one bounded control-safe project-create field.
732fn bounded_project_create_value(value: &str, limit: usize, allow_blank: bool) -> Option<String> {
733    let value = value.trim();
734    let length = value.chars().count();
735    if value.chars().any(char::is_control) || length > limit || (!allow_blank && length == 0) {
736        return None;
737    }
738    (!value.is_empty()).then(|| value.to_owned())
739}
740
741/// Normalizes one optional field while distinguishing blank from invalid.
742fn optional_project_create_value(value: &str, limit: usize) -> Result<Option<String>, CliError> {
743    let trimmed = value.trim();
744    if trimmed.is_empty() {
745        return Ok(None);
746    }
747    bounded_project_create_value(trimmed, limit, false)
748        .map(Some)
749        .ok_or(CliError::InvalidProjectCreateCommand)
750}
751
752/// Parses `projects setup <project_id>`.
753fn parse_project_setup_seen(args: &[String]) -> Result<Command, CliError> {
754    let (project_id, tail) =
755        take_required_position(args, "project_id", PROJECT_SETUP_SEEN_NEXT_STEP)?;
756    let (options, json) = parse_project_setup_seen_flags(tail.as_slice())?;
757    Ok(Command::ProjectSetupSeen {
758        project_id,
759        options,
760        json,
761    })
762}
763
764/// Parses flags for backend setup seen calls.
765fn parse_project_setup_seen_flags(
766    args: &[String],
767) -> Result<(ProjectSetupSeenOptions, bool), CliError> {
768    let mut options = ProjectSetupSeenOptions::default();
769    let mut json = false;
770    let mut seen = Vec::new();
771    let mut index = 0;
772
773    while let Some(arg) = args.get(index) {
774        let (flag, inline_value) = split_project_setup_seen_inline_value(arg.as_str());
775        match flag {
776            "--json" if inline_value.is_none() => {
777                mark_project_setup_seen_flag(&mut seen, "--json")?;
778                json = true;
779            }
780            "--runtime" => {
781                mark_project_setup_seen_flag(&mut seen, "--runtime")?;
782                options.runtime = Some(project_setup_seen_flag_value(
783                    args,
784                    &mut index,
785                    "--runtime",
786                    inline_value,
787                )?);
788            }
789            "--source" => {
790                mark_project_setup_seen_flag(&mut seen, "--source")?;
791                options.source = Some(validate_project_setup_seen_source(
792                    project_setup_seen_flag_value(args, &mut index, "--source", inline_value)?
793                        .as_str(),
794                )?);
795            }
796            "--environment" | "--env" => {
797                mark_project_setup_seen_flag(&mut seen, "--environment")?;
798                let visible_flag = if flag == "--env" {
799                    "--env"
800                } else {
801                    "--environment"
802                };
803                options.environment = Some(project_setup_seen_flag_value(
804                    args,
805                    &mut index,
806                    visible_flag,
807                    inline_value,
808                )?);
809            }
810            flag if flag.starts_with('-') => {
811                return Err(unknown_flag(flag, PROJECT_SETUP_SEEN_NEXT_STEP));
812            }
813            argument => {
814                return Err(CliError::UnexpectedArgument {
815                    argument: argument.to_owned(),
816                    command: "projects setup",
817                    next: PROJECT_SETUP_SEEN_NEXT_STEP,
818                });
819            }
820        }
821        index += 1;
822    }
823
824    Ok((options, json))
825}
826
827/// Splits a value-taking project setup flag.
828fn split_project_setup_seen_inline_value(flag: &str) -> (&str, Option<&str>) {
829    flag.split_once('=')
830        .map_or((flag, None), |(name, value)| (name, Some(value)))
831}
832
833/// Records a project setup flag and rejects duplicate occurrences.
834fn mark_project_setup_seen_flag(
835    seen: &mut Vec<&'static str>,
836    flag: &'static str,
837) -> Result<(), CliError> {
838    if seen.contains(&flag) {
839        return Err(CliError::DuplicateFlag {
840            flag,
841            next: project_setup_seen_duplicate_next(flag),
842        });
843    }
844    seen.push(flag);
845    Ok(())
846}
847
848/// Returns the recovery step for duplicate project setup flags.
849fn project_setup_seen_duplicate_next(flag: &'static str) -> &'static str {
850    match flag {
851        "--json" => "use --json once",
852        "--runtime" => "use --runtime once",
853        "--source" => "use --source once",
854        "--environment" => "use --environment once",
855        _ => "use the flag once",
856    }
857}
858
859/// Reads a value for a project setup flag.
860fn project_setup_seen_flag_value(
861    args: &[String],
862    index: &mut usize,
863    flag: &'static str,
864    inline_value: Option<&str>,
865) -> Result<String, CliError> {
866    if let Some(value) = inline_value {
867        if value.is_empty() {
868            return Err(missing_project_setup_seen_flag_value(flag));
869        }
870        return Ok(value.to_owned());
871    }
872    *index += 1;
873    let Some(value) = args.get(*index) else {
874        return Err(missing_project_setup_seen_flag_value(flag));
875    };
876    if value.starts_with('-') {
877        return Err(missing_project_setup_seen_flag_value(flag));
878    }
879    Ok(value.clone())
880}
881
882/// Builds a missing-value error for project setup flags.
883fn missing_project_setup_seen_flag_value(flag: &'static str) -> CliError {
884    CliError::MissingFlagValue {
885        flag,
886        next: project_setup_seen_missing_value_next(flag),
887    }
888}
889
890/// Returns the recovery step for missing project setup flag values.
891fn project_setup_seen_missing_value_next(flag: &'static str) -> &'static str {
892    match flag {
893        "--runtime" => "provide a value after --runtime",
894        "--source" => PROJECT_SETUP_SOURCE_NEXT_STEP,
895        "--environment" => "provide a value after --environment",
896        "--env" => "provide a value after --env",
897        _ => "provide a value after the flag",
898    }
899}
900
901/// Validates setup source values accepted by the public backend contract.
902fn validate_project_setup_seen_source(source: &str) -> Result<String, CliError> {
903    match source {
904        "api" | "cli" | "sdk" => Ok(source.to_owned()),
905        other => Err(CliError::InvalidSetupSource(other.to_owned())),
906    }
907}
908
909/// Parses `status`.
910fn parse_status(args: &[String]) -> Result<Command, CliError> {
911    let flags = parse_flags(args, FlagScope::Status)?;
912    Ok(Command::Status {
913        json: flags.is_json(),
914    })
915}
916
917/// Parses the closed authenticated account-usage read grammar.
918fn parse_usage(args: &[String]) -> Result<Command, CliError> {
919    match args {
920        [] => Ok(Command::Usage { json: false }),
921        [flag] if flag == "--json" => Ok(Command::Usage { json: true }),
922        _ => Err(CliError::InvalidUsageCommand),
923    }
924}
925
926/// Parses bare status-compatible doctor or one strict project-scoped diagnostic.
927fn parse_doctor(args: &[String]) -> Result<Command, CliError> {
928    if args.iter().all(|arg| arg == "--json") {
929        return parse_status(args);
930    }
931
932    let mut project_id = None;
933    let mut json = false;
934    let mut index = 0;
935    while let Some(argument) = args.get(index) {
936        if let Some(value) = argument
937            .strip_prefix("--project=")
938            .or_else(|| argument.strip_prefix("--project-id="))
939        {
940            if project_id.is_some() || !crate::ids::is_uuid(value) {
941                return Err(CliError::InvalidDoctorCommand);
942            }
943            project_id = Some(value.to_owned());
944            index += 1;
945            continue;
946        }
947        match argument.as_str() {
948            "--json" if !json => json = true,
949            "--project" | "--project-id" if project_id.is_none() => {
950                index += 1;
951                let Some(value) = args.get(index) else {
952                    return Err(CliError::InvalidDoctorCommand);
953                };
954                if !crate::ids::is_uuid(value) {
955                    return Err(CliError::InvalidDoctorCommand);
956                }
957                project_id = Some(value.clone());
958            }
959            _ => return Err(CliError::InvalidDoctorCommand),
960        }
961        index += 1;
962    }
963
964    project_id.map_or(Err(CliError::InvalidDoctorCommand), |project_id| {
965        Ok(Command::Doctor { project_id, json })
966    })
967}
968
969/// Parses `version`.
970fn parse_version(args: &[String]) -> Result<Command, CliError> {
971    let flags = parse_flags(args, FlagScope::Version)?;
972    Ok(Command::Version {
973        json: flags.is_json(),
974    })
975}
976
977/// Takes one required positional argument and rejects flags in its place.
978fn take_required_arg<'a>(
979    args: &'a [String],
980    argument: &'static str,
981    next: &'static str,
982) -> Result<(&'a str, &'a [String]), CliError> {
983    let Some((value, rest)) = args.split_first() else {
984        return Err(CliError::MissingArgument { argument, next });
985    };
986    if value.starts_with('-') {
987        return Err(CliError::MissingArgument { argument, next });
988    }
989    Ok((value.as_str(), rest))
990}
991
992/// Moves a leading JSON flag behind required positional arguments.
993fn move_leading_json_to_tail(args: &[String]) -> Vec<String> {
994    if args.first().is_some_and(|arg| arg == "--json") {
995        let mut normalized = Vec::with_capacity(args.len());
996        normalized.extend(args[1..].iter().cloned());
997        normalized.push(String::from("--json"));
998        normalized
999    } else {
1000        args.to_vec()
1001    }
1002}
1003
1004/// Returns whether a command has a required positional candidate after `--json`.
1005fn has_position_candidate(args: &[String]) -> bool {
1006    move_leading_json_to_tail(args)
1007        .first()
1008        .is_some_and(|arg| !arg.starts_with('-'))
1009}
1010
1011/// Returns whether args begin with an obvious copied trace id after optional `--json`.
1012fn has_trace_id_candidate(args: &[String]) -> bool {
1013    move_leading_json_to_tail(args)
1014        .first()
1015        .is_some_and(|arg| is_trace_id(arg))
1016}
1017
1018/// Takes a required positional argument after tolerating a leading JSON flag.
1019fn take_required_position(
1020    args: &[String],
1021    argument: &'static str,
1022    next: &'static str,
1023) -> Result<(String, Vec<String>), CliError> {
1024    let normalized = move_leading_json_to_tail(args);
1025    let (value, rest) = take_required_arg(normalized.as_slice(), argument, next)?;
1026    Ok((value.to_owned(), rest.to_vec()))
1027}
1028
1029/// Parses `read`.
1030fn parse_read(args: &[String]) -> Result<Command, CliError> {
1031    let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
1032    let resource = normalize_read_resource(resource.as_str());
1033    if is_recency_read_verb(resource) {
1034        return parse_read_verb(resource, rest.as_slice());
1035    }
1036    if is_known_issue_status(resource) && has_status_first_issue_resource_candidate(rest.as_slice())
1037    {
1038        return parse_status_first_issue_read(resource, rest.as_slice());
1039    }
1040    parse_read_resource(resource, rest.as_slice())
1041}
1042
1043/// Normalizes safe singular collection words behind `read`.
1044fn normalize_read_resource(resource: &str) -> &str {
1045    match resource {
1046        "log" => "logs",
1047        "release" => "releases",
1048        _ => resource,
1049    }
1050}
1051
1052/// Parses natural read-only verbs such as `show logs`.
1053fn parse_read_verb(verb: &str, args: &[String]) -> Result<Command, CliError> {
1054    let rewritten_args = recency_count_shortcut_args(verb, args);
1055    let args = rewritten_args.as_deref().unwrap_or(args);
1056    let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
1057    if is_known_issue_status(resource.as_str())
1058        && has_status_first_issue_resource_candidate(rest.as_slice())
1059    {
1060        return parse_status_first_issue_read(resource.as_str(), rest.as_slice());
1061    }
1062    let resource = normalize_read_verb_resource(verb, resource.as_str());
1063    parse_read_resource(resource, rest.as_slice())
1064}
1065
1066/// Rewrites `last 10 logs` to `last logs --limit 10`.
1067fn recency_count_shortcut_args(verb: &str, args: &[String]) -> Option<Vec<String>> {
1068    if !is_recency_read_verb(verb) {
1069        return None;
1070    }
1071    let normalized = move_leading_json_to_tail(args);
1072    let (count, tail) = normalized.split_first().filter(|(count, tail)| {
1073        !tail.is_empty() && count.chars().all(|char| char.is_ascii_digit())
1074    })?;
1075    let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1076    rewritten.push(tail[0].clone());
1077    let rest = &tail[1..];
1078    if let Some(separator_index) = rest.iter().position(|arg| arg == "--") {
1079        rewritten.extend(rest[..separator_index].iter().cloned());
1080        rewritten.push(String::from("--limit"));
1081        rewritten.push(count.clone());
1082        rewritten.extend(rest[separator_index..].iter().cloned());
1083        return Some(rewritten);
1084    }
1085    rewritten.extend(rest.iter().cloned());
1086    rewritten.push(String::from("--limit"));
1087    rewritten.push(count.clone());
1088    Some(rewritten)
1089}
1090
1091/// Returns whether a command is a natural read-only verb.
1092fn is_read_verb(value: &str) -> bool {
1093    matches!(value, "show" | "list" | "get") || is_recency_read_verb(value)
1094}
1095
1096/// Returns whether a command is a recency-flavored read alias.
1097fn is_recency_read_verb(value: &str) -> bool {
1098    matches!(value, "latest" | "recent" | "last" | "newest")
1099}
1100
1101/// Normalizes singular collection words behind natural read verbs.
1102fn normalize_read_verb_resource<'a>(verb: &str, resource: &'a str) -> &'a str {
1103    match (verb, resource) {
1104        ("list" | "show" | "get", "log") => "logs",
1105        (alias, "log") if is_recency_read_verb(alias) => "logs",
1106        (alias, "issue") if is_recency_read_verb(alias) => "issues",
1107        ("list", "issue") => "issues",
1108        ("list" | "show" | "get", "release") => "releases",
1109        (alias, "release") if is_recency_read_verb(alias) => "releases",
1110        _ => resource,
1111    }
1112}
1113
1114/// Returns whether a command is a natural log search shortcut.
1115fn is_log_search_shortcut(command: &str) -> bool {
1116    matches!(command, "search" | "find" | "grep")
1117}
1118
1119/// Returns whether a log search form uses `--` to search help-looking text.
1120fn is_log_search_separator_literal(command: &str, args: &[String]) -> bool {
1121    literal_log_search_separator_index(command, args).is_some()
1122}
1123
1124/// Returns the static argument label for a natural log search shortcut.
1125fn log_search_shortcut_label(command: &str) -> &'static str {
1126    match command {
1127        "find" => "find",
1128        "grep" => "grep",
1129        _ => "search",
1130    }
1131}
1132
1133/// Parses natural log search shortcuts as `logs --search <text>`.
1134fn parse_search_shortcut(label: &'static str, args: &[String]) -> Result<Command, CliError> {
1135    let (query, tail) = take_search_query(args, label)?;
1136    let mut rest = Vec::with_capacity(tail.len() + 2);
1137    if query.starts_with('-') {
1138        rest.push(format!("--search={query}"));
1139    } else {
1140        rest.push(String::from("--search"));
1141        rest.push(query);
1142    }
1143    rest.extend(tail);
1144    parse_read_resource("logs", rest.as_slice())
1145}
1146
1147/// Takes leading search text, allowing unquoted multi-word query shortcuts.
1148fn take_search_query(
1149    args: &[String],
1150    argument: &'static str,
1151) -> Result<(String, Vec<String>), CliError> {
1152    let normalized = move_leading_json_to_tail(args);
1153    if normalized.first().is_some_and(|arg| arg == "--") {
1154        return take_separator_search_query(normalized.as_slice(), argument);
1155    }
1156    let query_word_count = normalized
1157        .iter()
1158        .take_while(|arg| !arg.starts_with('-'))
1159        .count();
1160    if query_word_count == 0 {
1161        return Err(CliError::MissingArgument {
1162            argument,
1163            next: SEARCH_NEXT_STEP,
1164        });
1165    }
1166    let query = normalized[..query_word_count].join(" ");
1167    let tail = normalized[query_word_count..].to_vec();
1168    Ok((query, tail))
1169}
1170
1171/// Takes search text after `--`, allowing literal flag-looking terms.
1172fn take_separator_search_query(
1173    args: &[String],
1174    argument: &'static str,
1175) -> Result<(String, Vec<String>), CliError> {
1176    let words = &args[1..];
1177    if words.is_empty() {
1178        return Err(CliError::MissingArgument {
1179            argument,
1180            next: SEARCH_NEXT_STEP,
1181        });
1182    }
1183    let has_trailing_json_mode = words.len() > 1 && words.last().is_some_and(|arg| arg == "--json");
1184    let query_end = if has_trailing_json_mode {
1185        words.len() - 1
1186    } else {
1187        words.len()
1188    };
1189    let query = words[..query_end].join(" ");
1190    let tail = if has_trailing_json_mode {
1191        vec![String::from("--json")]
1192    } else {
1193        Vec::new()
1194    };
1195    Ok((query, tail))
1196}
1197
1198/// Parses `read` resource arguments or top-level read shortcuts.
1199fn parse_read_resource(resource: &str, rest: &[String]) -> Result<Command, CliError> {
1200    let (target, flags) = match resource {
1201        "logs" => parse_log_list_read(rest)?,
1202        alias if is_issue_collection_alias(alias) && has_issue_id_candidate(rest) => {
1203            return parse_issue_detail_or_status(rest);
1204        }
1205        alias if is_issue_collection_alias(alias) => parse_issue_list_read(rest)?,
1206        alias if is_action_collection_alias(alias) => parse_action_list_read(rest)?,
1207        "releases" => parse_list_read(
1208            ReadTarget::Releases,
1209            rest,
1210            "read releases",
1211            READ_RELEASES_NEXT_STEP,
1212            &[
1213                "--name",
1214                "--user",
1215                "--distinct-id",
1216                "--trace",
1217                "--trace-id",
1218                "--level",
1219                "--severity",
1220                "--search",
1221                "--status",
1222                "--min-duration-ms",
1223            ],
1224        )?,
1225        "traces" | "spans" if has_trace_id_candidate(rest) => {
1226            return parse_trace_detail_or_explain(rest);
1227        }
1228        "traces" | "spans" => parse_trace_list_read(rest)?,
1229        "trace" => return parse_trace_detail_or_explain(rest),
1230        "span" if has_position_candidate(rest) => {
1231            return parse_trace_detail_or_explain(rest);
1232        }
1233        "issue" if has_issue_status_candidate(rest) => parse_issue_list_read(rest)?,
1234        "issue" => return parse_issue_detail_or_status(rest),
1235        other => return Err(unknown_read_resource(other)),
1236    };
1237    let json = flags.is_json();
1238    let options = flags.into_read_options();
1239    validate_read_filters(&target, &options)?;
1240
1241    Ok(Command::Read {
1242        target,
1243        options: Box::new(options),
1244        json,
1245    })
1246}
1247
1248/// Returns whether a resource word is an issue list alias.
1249fn is_issue_collection_alias(value: &str) -> bool {
1250    matches!(
1251        value,
1252        "issues" | "errors" | "error" | "exceptions" | "exception"
1253    )
1254}
1255
1256/// Returns whether a resource word can follow a status-first issue shortcut.
1257fn is_status_first_issue_collection_alias(value: &str) -> bool {
1258    value == "issue" || is_issue_collection_alias(value)
1259}
1260
1261/// Returns whether args begin with an issue collection after a status word.
1262fn has_status_first_issue_resource_candidate(args: &[String]) -> bool {
1263    move_leading_json_to_tail(args)
1264        .first()
1265        .is_some_and(|arg| is_status_first_issue_collection_alias(arg))
1266}
1267
1268/// Returns whether args begin with an issue status after optional `--json`.
1269fn has_issue_status_candidate(args: &[String]) -> bool {
1270    move_leading_json_to_tail(args)
1271        .first()
1272        .is_some_and(|arg| is_known_issue_status(arg))
1273}
1274
1275/// Returns whether a resource word is an action list alias.
1276fn is_action_collection_alias(value: &str) -> bool {
1277    matches!(value, "actions" | "events" | "event" | "action")
1278}
1279
1280/// Parses log lists, accepting natural search and positional severity aliases.
1281fn parse_log_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1282    let args = log_shortcut_args(rest);
1283    parse_list_read(
1284        ReadTarget::Logs,
1285        args.as_slice(),
1286        "read logs",
1287        READ_LOGS_NEXT_STEP,
1288        &[
1289            "--name",
1290            "--user",
1291            "--distinct-id",
1292            "--status",
1293            "--min-duration-ms",
1294        ],
1295    )
1296}
1297
1298/// Parses issue/error lists, accepting a first positional status word.
1299fn parse_issue_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1300    let args = issue_status_shortcut_args(rest);
1301    parse_list_read(
1302        ReadTarget::Issues,
1303        args.as_slice(),
1304        "read issues",
1305        READ_ISSUES_NEXT_STEP,
1306        &[
1307            "--name",
1308            "--user",
1309            "--distinct-id",
1310            "--trace",
1311            "--trace-id",
1312            "--level",
1313            "--severity",
1314            "--search",
1315            "--min-duration-ms",
1316        ],
1317    )
1318}
1319
1320/// Parses `open issues` as `issues --status unresolved`.
1321fn parse_status_first_issue_read(status: &str, args: &[String]) -> Result<Command, CliError> {
1322    let canonical_status = normalize_status(status)?;
1323    let (resource, rest) = take_required_position(args, "resource", READ_ISSUES_NEXT_STEP)?;
1324    let resource = resource.as_str();
1325    if !is_status_first_issue_collection_alias(resource) {
1326        return Err(unknown_read_resource(resource));
1327    }
1328    let resource = if resource == "issue" {
1329        "issues"
1330    } else {
1331        resource
1332    };
1333    let mut rewritten = Vec::with_capacity(rest.len() + 2);
1334    rewritten.push(String::from("--status"));
1335    rewritten.push(canonical_status);
1336    rewritten.extend(rest);
1337    parse_read_resource(resource, rewritten.as_slice())
1338}
1339
1340/// Rewrites `issues open` to `issues --status unresolved`.
1341fn issue_status_shortcut_args(args: &[String]) -> Vec<String> {
1342    let normalized = move_leading_json_to_tail(args);
1343    let Some((status, tail)) = normalized
1344        .split_first()
1345        .and_then(|(status, tail)| normalize_status(status).ok().map(|value| (value, tail)))
1346    else {
1347        return args.to_vec();
1348    };
1349    let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1350    rewritten.push(String::from("--status"));
1351    rewritten.push(status);
1352    rewritten.extend(tail.iter().cloned());
1353    rewritten
1354}
1355
1356/// Parses action/event lists, accepting a first positional as `--name`.
1357fn parse_action_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1358    let args = action_name_shortcut_args(rest);
1359    parse_list_read(
1360        ReadTarget::Actions,
1361        args.as_slice(),
1362        "read actions",
1363        READ_ACTIONS_NEXT_STEP,
1364        ACTION_LIST_UNSUPPORTED_FLAGS,
1365    )
1366}
1367
1368/// Rewrites `events checkout_failed` to `actions --name checkout_failed`.
1369fn action_name_shortcut_args(args: &[String]) -> Vec<String> {
1370    let normalized = move_leading_json_to_tail(args);
1371    let Some((name, tail)) = normalized
1372        .split_first()
1373        .filter(|(name, _)| !name.starts_with('-') && !is_read_filter_word(name))
1374    else {
1375        return args.to_vec();
1376    };
1377    let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1378    rewritten.push(String::from("--name"));
1379    rewritten.push(name.clone());
1380    rewritten.extend(tail.iter().cloned());
1381    rewritten
1382}
1383
1384/// Returns whether args start with an obvious issue id after optional `--json`.
1385fn has_issue_id_candidate(args: &[String]) -> bool {
1386    move_leading_json_to_tail(args)
1387        .first()
1388        .is_some_and(|arg| is_issue_id(arg))
1389}
1390
1391/// Parses issue detail reads and issue-first mutation shortcuts.
1392fn parse_issue_detail_or_status(args: &[String]) -> Result<Command, CliError> {
1393    let (id, tail) = take_required_position(args, "issue_id", "provide an issue id")?;
1394    if has_issue_status_action(tail.as_slice()) {
1395        return parse_issue_first_status_shortcut(id, tail.as_slice());
1396    }
1397    if let Some(command) =
1398        parse_detail_explain_suffix(ExplainTarget::Issue(id.clone()), tail.as_slice())?
1399    {
1400        return Ok(command);
1401    }
1402    let target = ReadTarget::Issue(id);
1403    let flags = parse_detail_read_flags(
1404        tail.as_slice(),
1405        "read issue",
1406        READ_ISSUE_NEXT_STEP,
1407        ISSUE_DETAIL_UNSUPPORTED_FLAGS,
1408    )?;
1409    let json = flags.is_json();
1410    let options = flags.into_read_options();
1411    validate_read_filters(&target, &options)?;
1412
1413    Ok(Command::Read {
1414        target,
1415        options: Box::new(options),
1416        json,
1417    })
1418}
1419
1420/// Parses a list read after rejecting filters the target cannot apply.
1421fn parse_list_read(
1422    target: ReadTarget,
1423    args: &[String],
1424    command: &'static str,
1425    next: &'static str,
1426    unsupported_flags: &[&str],
1427) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1428    reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
1429    Ok((target, parse_flags(args, FlagScope::Read)?))
1430}
1431
1432/// Rejects target-inapplicable read filters before parsing values.
1433fn reject_unsupported_read_flags(
1434    args: &[String],
1435    command: &'static str,
1436    next: &'static str,
1437    unsupported_flags: &[&str],
1438) -> Result<(), CliError> {
1439    let mut index = 0;
1440    let mut seen = Vec::new();
1441    while let Some(arg) = args.get(index) {
1442        let (flag, inline_value) = arg
1443            .split_once('=')
1444            .map_or((arg.as_str(), None), |(name, value)| (name, Some(value)));
1445        if !is_read_value_flag(flag) {
1446            if flag == "--json" && inline_value.is_none() {
1447                if seen.contains(&"--json") {
1448                    return Ok(());
1449                }
1450                seen.push("--json");
1451                index += 1;
1452                continue;
1453            }
1454            if inline_value.is_some() && is_simple_flag(flag) {
1455                return Err(CliError::UnsupportedFlag {
1456                    flag: arg.to_owned(),
1457                    command,
1458                    next,
1459                });
1460            }
1461            if arg.starts_with('-') {
1462                return Err(unknown_flag(arg, next));
1463            }
1464            return Ok(());
1465        }
1466        if unsupported_flags.contains(&flag) {
1467            return Err(CliError::UnsupportedFlag {
1468                flag: user_facing_read_flag(flag).to_owned(),
1469                command,
1470                next,
1471            });
1472        }
1473        if let Some(canonical) = read_value_canonical_flag(flag) {
1474            if seen.contains(&canonical) {
1475                return Ok(());
1476            }
1477            seen.push(canonical);
1478        }
1479        if inline_value.is_some_and(str::is_empty) {
1480            return Ok(());
1481        }
1482        if inline_value.is_some_and(|value| has_invalid_supported_read_value(flag, value)) {
1483            return Ok(());
1484        }
1485        if inline_value.is_none() {
1486            let Some(value) = args.get(index + 1) else {
1487                return Ok(());
1488            };
1489            if value.starts_with('-') {
1490                return Ok(());
1491            }
1492            if has_invalid_supported_read_value(flag, value) {
1493                return Ok(());
1494            }
1495            index += 1;
1496        }
1497        index += 1;
1498    }
1499    Ok(())
1500}
1501
1502/// Returns the duplicate-tracking key for a read value flag.
1503fn read_value_canonical_flag(flag: &str) -> Option<&'static str> {
1504    let canonical = match flag {
1505        "--name" => "--name",
1506        "--service" | "--service-name" => "--service",
1507        "--since" => "--since",
1508        "--user" | "--distinct-id" => "--user",
1509        "--trace" | "--trace-id" => "--trace",
1510        "--level" | "--severity" => "--severity",
1511        "--search" => "--search",
1512        "--project" | "--project-id" => "--project",
1513        "--release" => "--release",
1514        "--environment" | "--env" => "--environment",
1515        "--status" => "--status",
1516        "--limit" => "--limit",
1517        "--min-duration-ms" => "--min-duration-ms",
1518        "--pagination" => "--pagination",
1519        "--cursor-time" => "--cursor-time",
1520        "--cursor-id" => "--cursor-id",
1521        _ => return None,
1522    };
1523    Some(canonical)
1524}
1525
1526/// Returns the canonical flag name to show in read-filter recovery output.
1527fn user_facing_read_flag(flag: &str) -> &str {
1528    match flag {
1529        "--level" => "--severity",
1530        "--service-name" => "--service",
1531        other => other,
1532    }
1533}
1534
1535/// Returns whether a supported read flag has a value that should be reported first.
1536fn has_invalid_supported_read_value(flag: &str, value: &str) -> bool {
1537    match flag {
1538        "--level" | "--severity" => !is_known_log_level(value),
1539        "--status" => !is_known_issue_status(value),
1540        "--limit" => value.parse::<u32>().map_or(true, |limit| limit == 0),
1541        "--min-duration-ms" => validate_min_duration(value).is_err(),
1542        "--pagination" => value != "cursor",
1543        _ => false,
1544    }
1545}
1546
1547/// Returns whether a value is in the log-level vocabulary.
1548fn is_known_log_level(value: &str) -> bool {
1549    normalize_log_level(value).is_ok()
1550}
1551
1552/// Returns whether a positional log search word should stay a recoverable error.
1553fn is_ambiguous_log_search_word(value: &str) -> bool {
1554    is_read_filter_word(value) || value.contains('@') || is_trace_id(value)
1555}
1556
1557/// Returns whether a value is in the issue-status vocabulary.
1558fn is_known_issue_status(value: &str) -> bool {
1559    normalize_status(value).is_ok()
1560}
1561
1562/// Returns whether a flag is a value-taking read filter.
1563fn is_read_value_flag(flag: &str) -> bool {
1564    matches!(
1565        flag,
1566        "--name"
1567            | "--service"
1568            | "--service-name"
1569            | "--since"
1570            | "--user"
1571            | "--distinct-id"
1572            | "--trace"
1573            | "--trace-id"
1574            | "--level"
1575            | "--severity"
1576            | "--search"
1577            | "--project"
1578            | "--project-id"
1579            | "--release"
1580            | "--environment"
1581            | "--env"
1582            | "--status"
1583            | "--limit"
1584            | "--min-duration-ms"
1585            | "--pagination"
1586            | "--cursor-time"
1587            | "--cursor-id"
1588    )
1589}
1590
1591/// Parses a trailing `explain` action after an issue or trace detail id.
1592fn parse_detail_explain_suffix(
1593    target: ExplainTarget,
1594    args: &[String],
1595) -> Result<Option<Command>, CliError> {
1596    let normalized = move_leading_json_to_tail(args);
1597    if normalized.first().is_none_or(|arg| arg != "explain") {
1598        return Ok(None);
1599    }
1600    Ok(Some(Command::Explain {
1601        target,
1602        json: parse_flags(&normalized[1..], FlagScope::Explain)?.is_json(),
1603    }))
1604}
1605
1606/// Parses detail read filters after rejecting list-only filters.
1607fn parse_detail_read_flags(
1608    args: &[String],
1609    command: &'static str,
1610    next: &'static str,
1611    unsupported_flags: &[&str],
1612) -> Result<crate::flags::Flags, CliError> {
1613    reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
1614    parse_flags(args, FlagScope::Read)
1615}
1616
1617/// Parses an obvious pasted issue or trace id as a detail read shortcut.
1618fn parse_pasted_detail_id(id: &str, args: &[String]) -> Result<Command, CliError> {
1619    if is_issue_id(id) && has_issue_status_action(args) {
1620        return parse_issue_first_status_shortcut(id.to_owned(), args);
1621    }
1622    let explain_args = move_leading_json_to_tail(args);
1623    if explain_args.first().is_some_and(|arg| arg == "explain") {
1624        let target = infer_explain_target(id).ok_or_else(|| unknown_command(id))?;
1625        return Ok(Command::Explain {
1626            target,
1627            json: parse_flags(&explain_args[1..], FlagScope::Explain)?.is_json(),
1628        });
1629    }
1630    let (target, flags) = if is_trace_id(id) {
1631        (
1632            ReadTarget::Trace(id.to_owned()),
1633            parse_detail_read_flags(
1634                args,
1635                "read trace",
1636                READ_TRACE_NEXT_STEP,
1637                TRACE_DETAIL_UNSUPPORTED_FLAGS,
1638            )?,
1639        )
1640    } else if is_issue_id(id) {
1641        (
1642            ReadTarget::Issue(id.to_owned()),
1643            parse_detail_read_flags(
1644                args,
1645                "read issue",
1646                READ_ISSUE_NEXT_STEP,
1647                ISSUE_DETAIL_UNSUPPORTED_FLAGS,
1648            )?,
1649        )
1650    } else {
1651        return Err(unknown_command(id));
1652    };
1653    let json = flags.is_json();
1654    let options = flags.into_read_options();
1655    validate_read_filters(&target, &options)?;
1656
1657    Ok(Command::Read {
1658        target,
1659        options: Box::new(options),
1660        json,
1661    })
1662}
1663
1664/// Rejects filters that a read endpoint would otherwise ignore.
1665fn validate_read_filters(target: &ReadTarget, filters: &ReadOptions) -> Result<(), CliError> {
1666    let unsupported = match target {
1667        ReadTarget::Logs => filters
1668            .first_log_unsupported_flag()
1669            .map(|flag| (flag, "read logs", READ_LOGS_NEXT_STEP)),
1670        ReadTarget::Issues => filters
1671            .first_issue_list_unsupported_flag()
1672            .map(|flag| (flag, "read issues", READ_ISSUES_NEXT_STEP)),
1673        ReadTarget::Actions => filters
1674            .first_action_unsupported_flag()
1675            .map(|flag| (flag, "read actions", READ_ACTIONS_NEXT_STEP)),
1676        ReadTarget::Releases => filters
1677            .first_release_unsupported_flag()
1678            .map(|flag| (flag, "read releases", READ_RELEASES_NEXT_STEP)),
1679        ReadTarget::Traces => filters
1680            .first_trace_list_unsupported_flag()
1681            .map(|flag| (flag, "read traces", READ_TRACES_NEXT_STEP)),
1682        ReadTarget::Trace(_) => filters
1683            .first_trace_detail_unsupported_flag()
1684            .map(|flag| (flag, "read trace", READ_TRACE_NEXT_STEP)),
1685        ReadTarget::Issue(_) => filters
1686            .first_issue_detail_unsupported_flag()
1687            .map(|flag| (flag, "read issue", READ_ISSUE_NEXT_STEP)),
1688    };
1689
1690    if let Some((flag, command, next)) = unsupported {
1691        return Err(CliError::UnsupportedFlag {
1692            flag: flag.to_owned(),
1693            command,
1694            next,
1695        });
1696    }
1697    match target {
1698        ReadTarget::Logs => validate_read_cursor(filters, CliError::InvalidLogCursor)?,
1699        ReadTarget::Actions => validate_read_cursor(filters, CliError::InvalidActionCursor)?,
1700        ReadTarget::Issues => validate_read_cursor(filters, CliError::InvalidIssueCursor)?,
1701        ReadTarget::Releases | ReadTarget::Traces | ReadTarget::Trace(_) | ReadTarget::Issue(_) => {
1702        }
1703    }
1704    Ok(())
1705}
1706
1707/// Validates an explicit first-page or continuation cursor shape.
1708fn validate_read_cursor(
1709    filters: &ReadOptions,
1710    invalid_cursor: fn(String) -> CliError,
1711) -> Result<(), CliError> {
1712    match (
1713        filters.pagination.as_deref(),
1714        filters.cursor_time.as_ref(),
1715        filters.cursor_id.as_ref(),
1716    ) {
1717        (None | Some("cursor"), None, None) | (Some("cursor"), Some(_), Some(_)) => Ok(()),
1718        (None, _, _) => Err(invalid_cursor(String::from(
1719            "cursor fields require --pagination cursor",
1720        ))),
1721        (Some("cursor"), _, _) => Err(invalid_cursor(String::from(
1722            "--cursor-time and --cursor-id must be used together",
1723        ))),
1724        (Some(_), _, _) => Err(CliError::UnknownPagination),
1725    }
1726}
1727
1728/// Parses `explain`.
1729fn parse_explain(args: &[String]) -> Result<Command, CliError> {
1730    let (resource, rest) = take_required_position(args, "resource", EXPLAIN_RESOURCE_NEXT_STEP)?;
1731    let (target, tail) = match resource.as_str() {
1732        "issue" => {
1733            let (id, tail) =
1734                take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1735            (ExplainTarget::Issue(id), tail)
1736        }
1737        "trace" => {
1738            let (id, tail) =
1739                take_required_position(rest.as_slice(), "trace_id", "provide a trace id")?;
1740            (ExplainTarget::Trace(id), tail)
1741        }
1742        other => {
1743            if let Some(target) = infer_explain_target(other) {
1744                (target, rest)
1745            } else {
1746                return Err(unknown_resource(other, EXPLAIN_RESOURCE_NEXT_STEP));
1747            }
1748        }
1749    };
1750    let flags = parse_flags(tail.as_slice(), FlagScope::Explain)?;
1751    Ok(Command::Explain {
1752        target,
1753        json: flags.is_json(),
1754    })
1755}
1756
1757/// Parses `set`.
1758fn parse_set(args: &[String]) -> Result<Command, CliError> {
1759    let (resource, rest) = take_required_position(args, "resource", SET_RESOURCE_NEXT_STEP)?;
1760    if resource != "issue" {
1761        return Err(unknown_resource(resource.as_str(), SET_RESOURCE_NEXT_STEP));
1762    }
1763    let (id, rest) = take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1764    let (status, tail) =
1765        take_required_position(rest.as_slice(), "status", ISSUE_STATUS_ARGUMENT_NEXT_STEP)?;
1766    let status = normalize_status(status.as_str())?;
1767    let flags = parse_flags(tail.as_slice(), FlagScope::Set)?;
1768
1769    Ok(Command::Set {
1770        target: SetTarget::IssueStatus { id, status },
1771        json: flags.is_json(),
1772    })
1773}