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