1mod 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
36const HELP_NEXT_STEP: &str = "run logbrew --help";
38const READ_RESOURCE_NEXT_STEP: &str =
40 "choose one of logs, issues, actions, releases, traces, trace, issue";
41const READ_TRACE_ALIAS_NEXT_STEP: &str =
43 "use singular trace with an id: logbrew read trace <trace_id>";
44const TRACE_COMMAND_NEXT_STEP: &str =
46 "use logbrew trace <trace_id> or logbrew explain trace <trace_id>";
47const READ_TRACE_NEXT_STEP: &str = "run logbrew read trace --help";
49const READ_ISSUE_NEXT_STEP: &str = "run logbrew read issue --help";
51const READ_LOGS_NEXT_STEP: &str = "run logbrew read logs --help";
53const SEARCH_NEXT_STEP: &str = "provide search text or run logbrew logs --help";
55const READ_ISSUES_NEXT_STEP: &str = "run logbrew read issues --help";
57const READ_ACTIONS_NEXT_STEP: &str = "run logbrew read actions --help";
59const READ_RELEASES_NEXT_STEP: &str = "run logbrew read releases --help";
61const READ_TRACES_NEXT_STEP: &str = "run logbrew read traces --help";
63const PROJECTS_NEXT_STEP: &str = "run logbrew projects --help";
65const PROJECT_SETUP_SEEN_NEXT_STEP: &str = "run logbrew projects setup <project_id> --help";
67const PROJECT_SETUP_SOURCE_NEXT_STEP: &str = "use --source api, cli, or sdk";
69const WATCH_RESOURCE_NEXT_STEP: &str = "choose logs, issues, actions, or omit a resource";
71const EXPLAIN_RESOURCE_NEXT_STEP: &str = "choose issue or trace";
73const SET_RESOURCE_NEXT_STEP: &str = "choose issue";
75const 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];
92const 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];
114const ACTION_LIST_UNSUPPORTED_FLAGS: &[&str] = &[
116 "--trace",
117 "--trace-id",
118 "--level",
119 "--severity",
120 "--search",
121 "--status",
122 "--min-duration-ms",
123];
124
125pub 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
139fn 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
233fn 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
246fn 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
269fn 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 .filter(|value| is_canonical_lower_uuid(value))
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#[derive(Default)]
295struct NativeDebugScope {
296 project_id: String,
298 release: String,
300 environment: String,
302 service: String,
304 image_uuid: Option<String>,
306 expected_image_uuids: Vec<String>,
308 architecture: Option<String>,
310 dry_run: bool,
312 json: bool,
314}
315
316fn 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 .filter(|value| is_canonical_lower_uuid(value))
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.clone());
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
406fn 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
414fn is_canonical_lower_uuid(value: &str) -> bool {
416 value.len() == 36
417 && value.bytes().enumerate().all(|(index, byte)| {
418 matches!(index, 8 | 13 | 18 | 23) && byte == b'-'
419 || !matches!(index, 8 | 13 | 18 | 23)
420 && (byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
421 })
422}
423
424fn parse_investigate(args: &[String]) -> Result<Command, CliError> {
426 let normalized = move_leading_json_to_tail(args);
427 match normalized.as_slice() {
428 [resource, issue_id]
429 if resource == "issue" && is_safe_investigation_issue_id(issue_id.as_str()) =>
430 {
431 Ok(Command::InvestigateIssue {
432 issue_id: issue_id.clone(),
433 json: false,
434 })
435 }
436 [resource, issue_id, json]
437 if resource == "issue"
438 && is_safe_investigation_issue_id(issue_id.as_str())
439 && json == "--json" =>
440 {
441 Ok(Command::InvestigateIssue {
442 issue_id: issue_id.clone(),
443 json: true,
444 })
445 }
446 _ => Err(CliError::InvalidInvestigationCommand),
447 }
448}
449
450fn is_safe_investigation_issue_id(value: &str) -> bool {
452 value.len() == 36
453 && value.bytes().enumerate().all(|(index, byte)| {
454 matches!(index, 8 | 13 | 18 | 23) && byte == b'-'
455 || !matches!(index, 8 | 13 | 18 | 23)
456 && (byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
457 })
458}
459
460fn parse_discovery_help(topic: HelpTopic, args: &[String]) -> Result<Command, CliError> {
462 validate_help_flags(args)?;
463 Ok(Command::Help {
464 topic,
465 json: args.iter().any(|arg| arg == "--json"),
466 })
467}
468
469fn parse_global_json(values: &[String], tail: &[String]) -> Result<Command, CliError> {
471 if global_json_tail_has_duplicate(tail) {
472 return Err(CliError::DuplicateFlag {
473 flag: "--json",
474 next: "use --json once",
475 });
476 }
477 if tail.is_empty() {
478 return Ok(Command::Help {
479 topic: HelpTopic::Root,
480 json: true,
481 });
482 }
483
484 let mut normalized = Vec::with_capacity(values.len());
485 if let Some(program) = values.first() {
486 normalized.push(program.clone());
487 }
488 normalized.extend(tail.iter().cloned());
489 normalized.push(String::from("--json"));
490 parse_values(normalized.as_slice())
491}
492
493fn global_json_tail_has_duplicate(tail: &[String]) -> bool {
495 if !tail.iter().any(|arg| arg == "--json") {
496 return false;
497 }
498 let Some((command, rest)) = tail.split_first() else {
499 return false;
500 };
501 let Some(separator_index) = literal_log_search_separator_index(command, rest) else {
502 return true;
503 };
504 rest[..separator_index].iter().any(|arg| arg == "--json")
505}
506fn unknown_resource(resource: &str, next: &'static str) -> CliError {
508 CliError::UnknownResource {
509 resource: resource.to_owned(),
510 next,
511 }
512}
513
514fn unknown_flag(flag: &str, next: &'static str) -> CliError {
516 CliError::UnknownFlag {
517 flag: flag.to_owned(),
518 next,
519 }
520}
521
522fn unknown_read_resource(resource: &str) -> CliError {
524 unknown_resource(resource, read_resource_next_step(resource))
525}
526
527fn read_resource_next_step(resource: &str) -> &'static str {
529 match resource {
530 "trace" | "traces" | "span" | "spans" => READ_TRACE_ALIAS_NEXT_STEP,
531 _ => READ_RESOURCE_NEXT_STEP,
532 }
533}
534
535fn unknown_command(command: &str) -> CliError {
537 CliError::UnknownCommandName {
538 command: command.to_owned(),
539 next: unknown_command_next_step(command),
540 }
541}
542
543fn unknown_command_next_step(command: &str) -> &'static str {
545 match command {
546 "logg" | "lgs" => "did you mean logbrew logs?",
547 "action" | "event" | "events" => "did you mean logbrew actions?",
548 "releaze" | "rels" => "did you mean logbrew releases?",
549 "statuz" | "stats" => "did you mean logbrew status?",
550 "error" | "errors" | "exception" | "exceptions" => "did you mean logbrew issues?",
551 "trace" | "traces" | "span" | "spans" => TRACE_COMMAND_NEXT_STEP,
552 "env" | "environment" | "environments" => {
553 "use --environment <environment> with logs, issues, actions, releases, or traces"
554 }
555 alias if auth_namespace::is_help_alias(alias) => "run logbrew help auth",
556 _ => HELP_NEXT_STEP,
557 }
558}
559
560fn is_status_help_alias(value: &str) -> bool {
562 matches!(value, "status" | "health" | "ping" | "doctor")
563}
564
565fn is_examples_help_alias(value: &str) -> bool {
567 matches!(
568 value,
569 "example" | "examples" | "sample" | "samples" | "recipe" | "recipes"
570 )
571}
572
573fn is_setup_alias(value: &str) -> bool {
575 matches!(value, "setup" | "init" | "install" | "configure" | "sdk")
576}
577
578fn is_project_help_alias(value: &str) -> bool {
580 matches!(value, "project" | "projects")
581}
582
583fn is_watch_command_alias(value: &str) -> bool {
585 matches!(value, "watch" | "tail" | "follow" | "stream")
586}
587
588fn is_version_flag(value: &str) -> bool {
590 matches!(value, "--version" | "-V")
591}
592
593fn parse_login(args: &[String]) -> Result<Command, CliError> {
595 let flags = parse_flags(args, FlagScope::Login)?;
596 let json = flags.is_json();
597 Ok(Command::Login {
598 open_browser: flags.should_open_browser() && !json,
599 json,
600 })
601}
602
603fn parse_logout(args: &[String]) -> Result<Command, CliError> {
605 let flags = parse_flags(args, FlagScope::Logout)?;
606 Ok(Command::Logout {
607 json: flags.is_json(),
608 })
609}
610
611fn parse_setup(args: &[String]) -> Result<Command, CliError> {
613 if args.iter().any(|arg| arg == "--create-project") {
614 return parse_setup_create_project(args);
615 }
616 let flags = parse_flags(args, FlagScope::Setup)?;
617 Ok(Command::Setup {
618 auto: flags.is_auto(),
619 yes: flags.skip_prompts(),
620 json: flags.is_json(),
621 })
622}
623
624fn parse_setup_create_project(args: &[String]) -> Result<Command, CliError> {
626 let mut seen_create_project = false;
627 let mut seen_json = false;
628
629 for arg in args {
630 match arg.as_str() {
631 "--create-project" => {
632 if std::mem::replace(&mut seen_create_project, true) {
633 return Err(CliError::DuplicateFlag {
634 flag: "--create-project",
635 next: "use --create-project once",
636 });
637 }
638 }
639 "--json" => {
640 if std::mem::replace(&mut seen_json, true) {
641 return Err(CliError::DuplicateFlag {
642 flag: "--json",
643 next: "use --json once",
644 });
645 }
646 }
647 "--help" | "-h" => {}
648 flag if flag.starts_with('-') => {
649 return Err(unknown_flag(flag, PROJECTS_NEXT_STEP));
650 }
651 argument => {
652 return Err(CliError::UnexpectedArgument {
653 argument: argument.to_owned(),
654 command: "setup",
655 next: PROJECTS_NEXT_STEP,
656 });
657 }
658 }
659 }
660
661 Ok(Command::Help {
662 topic: HelpTopic::Projects,
663 json: seen_json,
664 })
665}
666
667fn parse_project(args: &[String]) -> Result<Command, CliError> {
669 let normalized = move_leading_json_to_tail(args);
670 if let Some((subcommand, tail)) = normalized.split_first()
671 && subcommand == "create"
672 {
673 return parse_project_create(tail);
674 }
675 if let Some((subcommand, tail)) = normalized.split_first()
676 && subcommand == "setup"
677 && has_position_candidate(tail)
678 {
679 return parse_project_setup_seen(tail);
680 }
681 parse_discovery_help(HelpTopic::Projects, args)
682}
683
684fn parse_project_create(args: &[String]) -> Result<Command, CliError> {
686 let Some((name, tail)) = args.split_first() else {
687 return Err(CliError::InvalidProjectCreateCommand);
688 };
689 if name.starts_with('-') {
690 return Err(CliError::InvalidProjectCreateCommand);
691 }
692 let name = bounded_project_create_value(name, 120, false)
693 .ok_or(CliError::InvalidProjectCreateCommand)?;
694 if name.starts_with('-') {
695 return Err(CliError::InvalidProjectCreateCommand);
696 }
697 let mut runtime = None;
698 let mut environment = None;
699 let mut ingest_key_file = None;
700 let mut abandon_retry = false;
701 let mut json = false;
702 let mut index = 0;
703
704 while let Some(argument) = tail.get(index) {
705 let (flag, inline_value) = argument
706 .split_once('=')
707 .map_or((argument.as_str(), None), |(flag, value)| {
708 (flag, Some(value))
709 });
710 match flag {
711 "--runtime" if runtime.is_none() => {
712 let value = project_create_flag_value(tail, &mut index, inline_value)?;
713 runtime = optional_project_create_value(value, 64)?;
714 }
715 "--environment" if environment.is_none() => {
716 let value = project_create_flag_value(tail, &mut index, inline_value)?;
717 environment = optional_project_create_value(value, 64)?;
718 }
719 "--ingest-key-file" if ingest_key_file.is_none() => {
720 let value = project_create_flag_value(tail, &mut index, inline_value)?;
721 let trimmed = value.trim();
722 if trimmed.is_empty()
723 || trimmed.len() > 4096
724 || trimmed.chars().any(char::is_control)
725 {
726 return Err(CliError::InvalidProjectCreateCommand);
727 }
728 ingest_key_file = Some(trimmed.to_owned());
729 }
730 "--abandon-retry" if inline_value.is_none() && !abandon_retry => {
731 abandon_retry = true;
732 }
733 "--json" if inline_value.is_none() && !json => json = true,
734 _ => return Err(CliError::InvalidProjectCreateCommand),
735 }
736 index += 1;
737 }
738
739 let ingest_key_file = ingest_key_file.ok_or(CliError::InvalidProjectCreateCommand)?;
740 Ok(Command::ProjectCreate {
741 options: ProjectCreateOptions {
742 name,
743 runtime,
744 environment,
745 ingest_key_file,
746 abandon_retry,
747 },
748 json,
749 })
750}
751
752fn project_create_flag_value<'a>(
754 args: &'a [String],
755 index: &mut usize,
756 inline: Option<&'a str>,
757) -> Result<&'a str, CliError> {
758 if let Some(value) = inline {
759 return Ok(value);
760 }
761 *index += 1;
762 args.get(*index)
763 .map(String::as_str)
764 .filter(|value| !value.starts_with('-'))
765 .ok_or(CliError::InvalidProjectCreateCommand)
766}
767
768fn bounded_project_create_value(value: &str, limit: usize, allow_blank: bool) -> Option<String> {
770 let value = value.trim();
771 let length = value.chars().count();
772 if value.chars().any(char::is_control) || length > limit || (!allow_blank && length == 0) {
773 return None;
774 }
775 (!value.is_empty()).then(|| value.to_owned())
776}
777
778fn optional_project_create_value(value: &str, limit: usize) -> Result<Option<String>, CliError> {
780 let trimmed = value.trim();
781 if trimmed.is_empty() {
782 return Ok(None);
783 }
784 bounded_project_create_value(trimmed, limit, false)
785 .map(Some)
786 .ok_or(CliError::InvalidProjectCreateCommand)
787}
788
789fn parse_project_setup_seen(args: &[String]) -> Result<Command, CliError> {
791 let (project_id, tail) =
792 take_required_position(args, "project_id", PROJECT_SETUP_SEEN_NEXT_STEP)?;
793 let (options, json) = parse_project_setup_seen_flags(tail.as_slice())?;
794 Ok(Command::ProjectSetupSeen {
795 project_id,
796 options,
797 json,
798 })
799}
800
801fn parse_project_setup_seen_flags(
803 args: &[String],
804) -> Result<(ProjectSetupSeenOptions, bool), CliError> {
805 let mut options = ProjectSetupSeenOptions::default();
806 let mut json = false;
807 let mut seen = Vec::new();
808 let mut index = 0;
809
810 while let Some(arg) = args.get(index) {
811 let (flag, inline_value) = split_project_setup_seen_inline_value(arg.as_str());
812 match flag {
813 "--json" if inline_value.is_none() => {
814 mark_project_setup_seen_flag(&mut seen, "--json")?;
815 json = true;
816 }
817 "--runtime" => {
818 mark_project_setup_seen_flag(&mut seen, "--runtime")?;
819 options.runtime = Some(project_setup_seen_flag_value(
820 args,
821 &mut index,
822 "--runtime",
823 inline_value,
824 )?);
825 }
826 "--source" => {
827 mark_project_setup_seen_flag(&mut seen, "--source")?;
828 options.source = Some(validate_project_setup_seen_source(
829 project_setup_seen_flag_value(args, &mut index, "--source", inline_value)?
830 .as_str(),
831 )?);
832 }
833 "--environment" | "--env" => {
834 mark_project_setup_seen_flag(&mut seen, "--environment")?;
835 let visible_flag = if flag == "--env" {
836 "--env"
837 } else {
838 "--environment"
839 };
840 options.environment = Some(project_setup_seen_flag_value(
841 args,
842 &mut index,
843 visible_flag,
844 inline_value,
845 )?);
846 }
847 flag if flag.starts_with('-') => {
848 return Err(unknown_flag(flag, PROJECT_SETUP_SEEN_NEXT_STEP));
849 }
850 argument => {
851 return Err(CliError::UnexpectedArgument {
852 argument: argument.to_owned(),
853 command: "projects setup",
854 next: PROJECT_SETUP_SEEN_NEXT_STEP,
855 });
856 }
857 }
858 index += 1;
859 }
860
861 Ok((options, json))
862}
863
864fn split_project_setup_seen_inline_value(flag: &str) -> (&str, Option<&str>) {
866 flag.split_once('=')
867 .map_or((flag, None), |(name, value)| (name, Some(value)))
868}
869
870fn mark_project_setup_seen_flag(
872 seen: &mut Vec<&'static str>,
873 flag: &'static str,
874) -> Result<(), CliError> {
875 if seen.contains(&flag) {
876 return Err(CliError::DuplicateFlag {
877 flag,
878 next: project_setup_seen_duplicate_next(flag),
879 });
880 }
881 seen.push(flag);
882 Ok(())
883}
884
885fn project_setup_seen_duplicate_next(flag: &'static str) -> &'static str {
887 match flag {
888 "--json" => "use --json once",
889 "--runtime" => "use --runtime once",
890 "--source" => "use --source once",
891 "--environment" => "use --environment once",
892 _ => "use the flag once",
893 }
894}
895
896fn project_setup_seen_flag_value(
898 args: &[String],
899 index: &mut usize,
900 flag: &'static str,
901 inline_value: Option<&str>,
902) -> Result<String, CliError> {
903 if let Some(value) = inline_value {
904 if value.is_empty() {
905 return Err(missing_project_setup_seen_flag_value(flag));
906 }
907 return Ok(value.to_owned());
908 }
909 *index += 1;
910 let Some(value) = args.get(*index) else {
911 return Err(missing_project_setup_seen_flag_value(flag));
912 };
913 if value.starts_with('-') {
914 return Err(missing_project_setup_seen_flag_value(flag));
915 }
916 Ok(value.clone())
917}
918
919fn missing_project_setup_seen_flag_value(flag: &'static str) -> CliError {
921 CliError::MissingFlagValue {
922 flag,
923 next: project_setup_seen_missing_value_next(flag),
924 }
925}
926
927fn project_setup_seen_missing_value_next(flag: &'static str) -> &'static str {
929 match flag {
930 "--runtime" => "provide a value after --runtime",
931 "--source" => PROJECT_SETUP_SOURCE_NEXT_STEP,
932 "--environment" => "provide a value after --environment",
933 "--env" => "provide a value after --env",
934 _ => "provide a value after the flag",
935 }
936}
937
938fn validate_project_setup_seen_source(source: &str) -> Result<String, CliError> {
940 match source {
941 "api" | "cli" | "sdk" => Ok(source.to_owned()),
942 other => Err(CliError::InvalidSetupSource(other.to_owned())),
943 }
944}
945
946fn parse_status(args: &[String]) -> Result<Command, CliError> {
948 let flags = parse_flags(args, FlagScope::Status)?;
949 Ok(Command::Status {
950 json: flags.is_json(),
951 })
952}
953
954fn parse_usage(args: &[String]) -> Result<Command, CliError> {
956 match args {
957 [] => Ok(Command::Usage { json: false }),
958 [flag] if flag == "--json" => Ok(Command::Usage { json: true }),
959 _ => Err(CliError::InvalidUsageCommand),
960 }
961}
962
963fn parse_doctor(args: &[String]) -> Result<Command, CliError> {
965 if args.iter().all(|arg| arg == "--json") {
966 return parse_status(args);
967 }
968
969 let mut project_id = None;
970 let mut json = false;
971 let mut index = 0;
972 while let Some(argument) = args.get(index) {
973 if let Some(value) = argument
974 .strip_prefix("--project=")
975 .or_else(|| argument.strip_prefix("--project-id="))
976 {
977 if project_id.is_some() || !crate::ids::is_uuid(value) {
978 return Err(CliError::InvalidDoctorCommand);
979 }
980 project_id = Some(value.to_owned());
981 index += 1;
982 continue;
983 }
984 match argument.as_str() {
985 "--json" if !json => json = true,
986 "--project" | "--project-id" if project_id.is_none() => {
987 index += 1;
988 let Some(value) = args.get(index) else {
989 return Err(CliError::InvalidDoctorCommand);
990 };
991 if !crate::ids::is_uuid(value) {
992 return Err(CliError::InvalidDoctorCommand);
993 }
994 project_id = Some(value.clone());
995 }
996 _ => return Err(CliError::InvalidDoctorCommand),
997 }
998 index += 1;
999 }
1000
1001 project_id.map_or(Err(CliError::InvalidDoctorCommand), |project_id| {
1002 Ok(Command::Doctor { project_id, json })
1003 })
1004}
1005
1006fn parse_version(args: &[String]) -> Result<Command, CliError> {
1008 let flags = parse_flags(args, FlagScope::Version)?;
1009 Ok(Command::Version {
1010 json: flags.is_json(),
1011 })
1012}
1013
1014fn take_required_arg<'a>(
1016 args: &'a [String],
1017 argument: &'static str,
1018 next: &'static str,
1019) -> Result<(&'a str, &'a [String]), CliError> {
1020 let Some((value, rest)) = args.split_first() else {
1021 return Err(CliError::MissingArgument { argument, next });
1022 };
1023 if value.starts_with('-') {
1024 return Err(CliError::MissingArgument { argument, next });
1025 }
1026 Ok((value.as_str(), rest))
1027}
1028
1029fn move_leading_json_to_tail(args: &[String]) -> Vec<String> {
1031 if args.first().is_some_and(|arg| arg == "--json") {
1032 let mut normalized = Vec::with_capacity(args.len());
1033 normalized.extend(args[1..].iter().cloned());
1034 normalized.push(String::from("--json"));
1035 normalized
1036 } else {
1037 args.to_vec()
1038 }
1039}
1040
1041fn has_position_candidate(args: &[String]) -> bool {
1043 move_leading_json_to_tail(args)
1044 .first()
1045 .is_some_and(|arg| !arg.starts_with('-'))
1046}
1047
1048fn has_trace_id_candidate(args: &[String]) -> bool {
1050 move_leading_json_to_tail(args)
1051 .first()
1052 .is_some_and(|arg| is_trace_id(arg))
1053}
1054
1055fn take_required_position(
1057 args: &[String],
1058 argument: &'static str,
1059 next: &'static str,
1060) -> Result<(String, Vec<String>), CliError> {
1061 let normalized = move_leading_json_to_tail(args);
1062 let (value, rest) = take_required_arg(normalized.as_slice(), argument, next)?;
1063 Ok((value.to_owned(), rest.to_vec()))
1064}
1065
1066fn parse_read(args: &[String]) -> Result<Command, CliError> {
1068 let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
1069 let resource = normalize_read_resource(resource.as_str());
1070 if is_recency_read_verb(resource) {
1071 return parse_read_verb(resource, rest.as_slice());
1072 }
1073 if is_known_issue_status(resource) && has_status_first_issue_resource_candidate(rest.as_slice())
1074 {
1075 return parse_status_first_issue_read(resource, rest.as_slice());
1076 }
1077 parse_read_resource(resource, rest.as_slice())
1078}
1079
1080fn normalize_read_resource(resource: &str) -> &str {
1082 match resource {
1083 "log" => "logs",
1084 "release" => "releases",
1085 _ => resource,
1086 }
1087}
1088
1089fn parse_read_verb(verb: &str, args: &[String]) -> Result<Command, CliError> {
1091 let rewritten_args = recency_count_shortcut_args(verb, args);
1092 let args = rewritten_args.as_deref().unwrap_or(args);
1093 let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
1094 if is_known_issue_status(resource.as_str())
1095 && has_status_first_issue_resource_candidate(rest.as_slice())
1096 {
1097 return parse_status_first_issue_read(resource.as_str(), rest.as_slice());
1098 }
1099 let resource = normalize_read_verb_resource(verb, resource.as_str());
1100 parse_read_resource(resource, rest.as_slice())
1101}
1102
1103fn recency_count_shortcut_args(verb: &str, args: &[String]) -> Option<Vec<String>> {
1105 if !is_recency_read_verb(verb) {
1106 return None;
1107 }
1108 let normalized = move_leading_json_to_tail(args);
1109 let (count, tail) = normalized.split_first().filter(|(count, tail)| {
1110 !tail.is_empty() && count.chars().all(|char| char.is_ascii_digit())
1111 })?;
1112 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1113 rewritten.push(tail[0].clone());
1114 let rest = &tail[1..];
1115 if let Some(separator_index) = rest.iter().position(|arg| arg == "--") {
1116 rewritten.extend(rest[..separator_index].iter().cloned());
1117 rewritten.push(String::from("--limit"));
1118 rewritten.push(count.clone());
1119 rewritten.extend(rest[separator_index..].iter().cloned());
1120 return Some(rewritten);
1121 }
1122 rewritten.extend(rest.iter().cloned());
1123 rewritten.push(String::from("--limit"));
1124 rewritten.push(count.clone());
1125 Some(rewritten)
1126}
1127
1128fn is_read_verb(value: &str) -> bool {
1130 matches!(value, "show" | "list" | "get") || is_recency_read_verb(value)
1131}
1132
1133fn is_recency_read_verb(value: &str) -> bool {
1135 matches!(value, "latest" | "recent" | "last" | "newest")
1136}
1137
1138fn normalize_read_verb_resource<'a>(verb: &str, resource: &'a str) -> &'a str {
1140 match (verb, resource) {
1141 ("list" | "show" | "get", "log") => "logs",
1142 (alias, "log") if is_recency_read_verb(alias) => "logs",
1143 (alias, "issue") if is_recency_read_verb(alias) => "issues",
1144 ("list", "issue") => "issues",
1145 ("list" | "show" | "get", "release") => "releases",
1146 (alias, "release") if is_recency_read_verb(alias) => "releases",
1147 _ => resource,
1148 }
1149}
1150
1151fn is_log_search_shortcut(command: &str) -> bool {
1153 matches!(command, "search" | "find" | "grep")
1154}
1155
1156fn is_log_search_separator_literal(command: &str, args: &[String]) -> bool {
1158 literal_log_search_separator_index(command, args).is_some()
1159}
1160
1161fn log_search_shortcut_label(command: &str) -> &'static str {
1163 match command {
1164 "find" => "find",
1165 "grep" => "grep",
1166 _ => "search",
1167 }
1168}
1169
1170fn parse_search_shortcut(label: &'static str, args: &[String]) -> Result<Command, CliError> {
1172 let (query, tail) = take_search_query(args, label)?;
1173 let mut rest = Vec::with_capacity(tail.len() + 2);
1174 if query.starts_with('-') {
1175 rest.push(format!("--search={query}"));
1176 } else {
1177 rest.push(String::from("--search"));
1178 rest.push(query);
1179 }
1180 rest.extend(tail);
1181 parse_read_resource("logs", rest.as_slice())
1182}
1183
1184fn take_search_query(
1186 args: &[String],
1187 argument: &'static str,
1188) -> Result<(String, Vec<String>), CliError> {
1189 let normalized = move_leading_json_to_tail(args);
1190 if normalized.first().is_some_and(|arg| arg == "--") {
1191 return take_separator_search_query(normalized.as_slice(), argument);
1192 }
1193 let query_word_count = normalized
1194 .iter()
1195 .take_while(|arg| !arg.starts_with('-'))
1196 .count();
1197 if query_word_count == 0 {
1198 return Err(CliError::MissingArgument {
1199 argument,
1200 next: SEARCH_NEXT_STEP,
1201 });
1202 }
1203 let query = normalized[..query_word_count].join(" ");
1204 let tail = normalized[query_word_count..].to_vec();
1205 Ok((query, tail))
1206}
1207
1208fn take_separator_search_query(
1210 args: &[String],
1211 argument: &'static str,
1212) -> Result<(String, Vec<String>), CliError> {
1213 let words = &args[1..];
1214 if words.is_empty() {
1215 return Err(CliError::MissingArgument {
1216 argument,
1217 next: SEARCH_NEXT_STEP,
1218 });
1219 }
1220 let has_trailing_json_mode = words.len() > 1 && words.last().is_some_and(|arg| arg == "--json");
1221 let query_end = if has_trailing_json_mode {
1222 words.len() - 1
1223 } else {
1224 words.len()
1225 };
1226 let query = words[..query_end].join(" ");
1227 let tail = if has_trailing_json_mode {
1228 vec![String::from("--json")]
1229 } else {
1230 Vec::new()
1231 };
1232 Ok((query, tail))
1233}
1234
1235fn parse_read_resource(resource: &str, rest: &[String]) -> Result<Command, CliError> {
1237 let (target, flags) = match resource {
1238 "logs" => parse_log_list_read(rest)?,
1239 alias if is_issue_collection_alias(alias) && has_issue_id_candidate(rest) => {
1240 return parse_issue_detail_or_status(rest);
1241 }
1242 alias if is_issue_collection_alias(alias) => parse_issue_list_read(rest)?,
1243 alias if is_action_collection_alias(alias) => parse_action_list_read(rest)?,
1244 "releases" => parse_list_read(
1245 ReadTarget::Releases,
1246 rest,
1247 "read releases",
1248 READ_RELEASES_NEXT_STEP,
1249 &[
1250 "--name",
1251 "--user",
1252 "--distinct-id",
1253 "--trace",
1254 "--trace-id",
1255 "--level",
1256 "--severity",
1257 "--search",
1258 "--status",
1259 "--min-duration-ms",
1260 ],
1261 )?,
1262 "traces" | "spans" if has_trace_id_candidate(rest) => {
1263 return parse_trace_detail_or_explain(rest);
1264 }
1265 "traces" | "spans" => parse_trace_list_read(rest)?,
1266 "trace" => return parse_trace_detail_or_explain(rest),
1267 "span" if has_position_candidate(rest) => {
1268 return parse_trace_detail_or_explain(rest);
1269 }
1270 "issue" if has_issue_status_candidate(rest) => parse_issue_list_read(rest)?,
1271 "issue" => return parse_issue_detail_or_status(rest),
1272 other => return Err(unknown_read_resource(other)),
1273 };
1274 let json = flags.is_json();
1275 let options = flags.into_read_options();
1276 validate_read_filters(&target, &options)?;
1277
1278 Ok(Command::Read {
1279 target,
1280 options: Box::new(options),
1281 json,
1282 })
1283}
1284
1285fn is_issue_collection_alias(value: &str) -> bool {
1287 matches!(
1288 value,
1289 "issues" | "errors" | "error" | "exceptions" | "exception"
1290 )
1291}
1292
1293fn is_status_first_issue_collection_alias(value: &str) -> bool {
1295 value == "issue" || is_issue_collection_alias(value)
1296}
1297
1298fn has_status_first_issue_resource_candidate(args: &[String]) -> bool {
1300 move_leading_json_to_tail(args)
1301 .first()
1302 .is_some_and(|arg| is_status_first_issue_collection_alias(arg))
1303}
1304
1305fn has_issue_status_candidate(args: &[String]) -> bool {
1307 move_leading_json_to_tail(args)
1308 .first()
1309 .is_some_and(|arg| is_known_issue_status(arg))
1310}
1311
1312fn is_action_collection_alias(value: &str) -> bool {
1314 matches!(value, "actions" | "events" | "event" | "action")
1315}
1316
1317fn parse_log_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1319 let args = log_shortcut_args(rest);
1320 parse_list_read(
1321 ReadTarget::Logs,
1322 args.as_slice(),
1323 "read logs",
1324 READ_LOGS_NEXT_STEP,
1325 &[
1326 "--name",
1327 "--user",
1328 "--distinct-id",
1329 "--status",
1330 "--min-duration-ms",
1331 ],
1332 )
1333}
1334
1335fn parse_issue_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1337 let args = issue_status_shortcut_args(rest);
1338 parse_list_read(
1339 ReadTarget::Issues,
1340 args.as_slice(),
1341 "read issues",
1342 READ_ISSUES_NEXT_STEP,
1343 &[
1344 "--name",
1345 "--user",
1346 "--distinct-id",
1347 "--trace",
1348 "--trace-id",
1349 "--level",
1350 "--severity",
1351 "--search",
1352 "--min-duration-ms",
1353 ],
1354 )
1355}
1356
1357fn parse_status_first_issue_read(status: &str, args: &[String]) -> Result<Command, CliError> {
1359 let canonical_status = normalize_status(status)?;
1360 let (resource, rest) = take_required_position(args, "resource", READ_ISSUES_NEXT_STEP)?;
1361 let resource = resource.as_str();
1362 if !is_status_first_issue_collection_alias(resource) {
1363 return Err(unknown_read_resource(resource));
1364 }
1365 let resource = if resource == "issue" {
1366 "issues"
1367 } else {
1368 resource
1369 };
1370 let mut rewritten = Vec::with_capacity(rest.len() + 2);
1371 rewritten.push(String::from("--status"));
1372 rewritten.push(canonical_status);
1373 rewritten.extend(rest);
1374 parse_read_resource(resource, rewritten.as_slice())
1375}
1376
1377fn issue_status_shortcut_args(args: &[String]) -> Vec<String> {
1379 let normalized = move_leading_json_to_tail(args);
1380 let Some((status, tail)) = normalized
1381 .split_first()
1382 .and_then(|(status, tail)| normalize_status(status).ok().map(|value| (value, tail)))
1383 else {
1384 return args.to_vec();
1385 };
1386 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1387 rewritten.push(String::from("--status"));
1388 rewritten.push(status);
1389 rewritten.extend(tail.iter().cloned());
1390 rewritten
1391}
1392
1393fn parse_action_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1395 let args = action_name_shortcut_args(rest);
1396 parse_list_read(
1397 ReadTarget::Actions,
1398 args.as_slice(),
1399 "read actions",
1400 READ_ACTIONS_NEXT_STEP,
1401 ACTION_LIST_UNSUPPORTED_FLAGS,
1402 )
1403}
1404
1405fn action_name_shortcut_args(args: &[String]) -> Vec<String> {
1407 let normalized = move_leading_json_to_tail(args);
1408 let Some((name, tail)) = normalized
1409 .split_first()
1410 .filter(|(name, _)| !name.starts_with('-') && !is_read_filter_word(name))
1411 else {
1412 return args.to_vec();
1413 };
1414 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1415 rewritten.push(String::from("--name"));
1416 rewritten.push(name.clone());
1417 rewritten.extend(tail.iter().cloned());
1418 rewritten
1419}
1420
1421fn has_issue_id_candidate(args: &[String]) -> bool {
1423 move_leading_json_to_tail(args)
1424 .first()
1425 .is_some_and(|arg| is_issue_id(arg))
1426}
1427
1428fn parse_issue_detail_or_status(args: &[String]) -> Result<Command, CliError> {
1430 let (id, tail) = take_required_position(args, "issue_id", "provide an issue id")?;
1431 if has_issue_status_action(tail.as_slice()) {
1432 return parse_issue_first_status_shortcut(id, tail.as_slice());
1433 }
1434 if let Some(command) =
1435 parse_detail_explain_suffix(ExplainTarget::Issue(id.clone()), tail.as_slice())?
1436 {
1437 return Ok(command);
1438 }
1439 let target = ReadTarget::Issue(id);
1440 let flags = parse_detail_read_flags(
1441 tail.as_slice(),
1442 "read issue",
1443 READ_ISSUE_NEXT_STEP,
1444 ISSUE_DETAIL_UNSUPPORTED_FLAGS,
1445 )?;
1446 let json = flags.is_json();
1447 let options = flags.into_read_options();
1448 validate_read_filters(&target, &options)?;
1449
1450 Ok(Command::Read {
1451 target,
1452 options: Box::new(options),
1453 json,
1454 })
1455}
1456
1457fn parse_list_read(
1459 target: ReadTarget,
1460 args: &[String],
1461 command: &'static str,
1462 next: &'static str,
1463 unsupported_flags: &[&str],
1464) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1465 reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
1466 Ok((target, parse_flags(args, FlagScope::Read)?))
1467}
1468
1469fn reject_unsupported_read_flags(
1471 args: &[String],
1472 command: &'static str,
1473 next: &'static str,
1474 unsupported_flags: &[&str],
1475) -> Result<(), CliError> {
1476 let mut index = 0;
1477 let mut seen = Vec::new();
1478 while let Some(arg) = args.get(index) {
1479 let (flag, inline_value) = arg
1480 .split_once('=')
1481 .map_or((arg.as_str(), None), |(name, value)| (name, Some(value)));
1482 if !is_read_value_flag(flag) {
1483 if flag == "--json" && inline_value.is_none() {
1484 if seen.contains(&"--json") {
1485 return Ok(());
1486 }
1487 seen.push("--json");
1488 index += 1;
1489 continue;
1490 }
1491 if inline_value.is_some() && is_simple_flag(flag) {
1492 return Err(CliError::UnsupportedFlag {
1493 flag: arg.to_owned(),
1494 command,
1495 next,
1496 });
1497 }
1498 if arg.starts_with('-') {
1499 return Err(unknown_flag(arg, next));
1500 }
1501 return Ok(());
1502 }
1503 if unsupported_flags.contains(&flag) {
1504 return Err(CliError::UnsupportedFlag {
1505 flag: user_facing_read_flag(flag).to_owned(),
1506 command,
1507 next,
1508 });
1509 }
1510 if let Some(canonical) = read_value_canonical_flag(flag) {
1511 if seen.contains(&canonical) {
1512 return Ok(());
1513 }
1514 seen.push(canonical);
1515 }
1516 if inline_value.is_some_and(str::is_empty) {
1517 return Ok(());
1518 }
1519 if inline_value.is_some_and(|value| has_invalid_supported_read_value(flag, value)) {
1520 return Ok(());
1521 }
1522 if inline_value.is_none() {
1523 let Some(value) = args.get(index + 1) else {
1524 return Ok(());
1525 };
1526 if value.starts_with('-') {
1527 return Ok(());
1528 }
1529 if has_invalid_supported_read_value(flag, value) {
1530 return Ok(());
1531 }
1532 index += 1;
1533 }
1534 index += 1;
1535 }
1536 Ok(())
1537}
1538
1539fn read_value_canonical_flag(flag: &str) -> Option<&'static str> {
1541 let canonical = match flag {
1542 "--name" => "--name",
1543 "--service" | "--service-name" => "--service",
1544 "--since" => "--since",
1545 "--user" | "--distinct-id" => "--user",
1546 "--trace" | "--trace-id" => "--trace",
1547 "--level" | "--severity" => "--severity",
1548 "--search" => "--search",
1549 "--project" | "--project-id" => "--project",
1550 "--release" => "--release",
1551 "--environment" | "--env" => "--environment",
1552 "--status" => "--status",
1553 "--limit" => "--limit",
1554 "--min-duration-ms" => "--min-duration-ms",
1555 "--pagination" => "--pagination",
1556 "--cursor-time" => "--cursor-time",
1557 "--cursor-id" => "--cursor-id",
1558 _ => return None,
1559 };
1560 Some(canonical)
1561}
1562
1563fn user_facing_read_flag(flag: &str) -> &str {
1565 match flag {
1566 "--level" => "--severity",
1567 "--service-name" => "--service",
1568 other => other,
1569 }
1570}
1571
1572fn has_invalid_supported_read_value(flag: &str, value: &str) -> bool {
1574 match flag {
1575 "--level" | "--severity" => !is_known_log_level(value),
1576 "--status" => !is_known_issue_status(value),
1577 "--limit" => value.parse::<u32>().map_or(true, |limit| limit == 0),
1578 "--min-duration-ms" => validate_min_duration(value).is_err(),
1579 "--pagination" => value != "cursor",
1580 _ => false,
1581 }
1582}
1583
1584fn is_known_log_level(value: &str) -> bool {
1586 normalize_log_level(value).is_ok()
1587}
1588
1589fn is_ambiguous_log_search_word(value: &str) -> bool {
1591 is_read_filter_word(value) || value.contains('@') || is_trace_id(value)
1592}
1593
1594fn is_known_issue_status(value: &str) -> bool {
1596 normalize_status(value).is_ok()
1597}
1598
1599fn is_read_value_flag(flag: &str) -> bool {
1601 matches!(
1602 flag,
1603 "--name"
1604 | "--service"
1605 | "--service-name"
1606 | "--since"
1607 | "--user"
1608 | "--distinct-id"
1609 | "--trace"
1610 | "--trace-id"
1611 | "--level"
1612 | "--severity"
1613 | "--search"
1614 | "--project"
1615 | "--project-id"
1616 | "--release"
1617 | "--environment"
1618 | "--env"
1619 | "--status"
1620 | "--limit"
1621 | "--min-duration-ms"
1622 | "--pagination"
1623 | "--cursor-time"
1624 | "--cursor-id"
1625 )
1626}
1627
1628fn parse_detail_explain_suffix(
1630 target: ExplainTarget,
1631 args: &[String],
1632) -> Result<Option<Command>, CliError> {
1633 let normalized = move_leading_json_to_tail(args);
1634 if normalized.first().is_none_or(|arg| arg != "explain") {
1635 return Ok(None);
1636 }
1637 Ok(Some(Command::Explain {
1638 target,
1639 json: parse_flags(&normalized[1..], FlagScope::Explain)?.is_json(),
1640 }))
1641}
1642
1643fn parse_detail_read_flags(
1645 args: &[String],
1646 command: &'static str,
1647 next: &'static str,
1648 unsupported_flags: &[&str],
1649) -> Result<crate::flags::Flags, CliError> {
1650 reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
1651 parse_flags(args, FlagScope::Read)
1652}
1653
1654fn parse_pasted_detail_id(id: &str, args: &[String]) -> Result<Command, CliError> {
1656 if is_issue_id(id) && has_issue_status_action(args) {
1657 return parse_issue_first_status_shortcut(id.to_owned(), args);
1658 }
1659 let explain_args = move_leading_json_to_tail(args);
1660 if explain_args.first().is_some_and(|arg| arg == "explain") {
1661 let target = infer_explain_target(id).ok_or_else(|| unknown_command(id))?;
1662 return Ok(Command::Explain {
1663 target,
1664 json: parse_flags(&explain_args[1..], FlagScope::Explain)?.is_json(),
1665 });
1666 }
1667 let (target, flags) = if is_trace_id(id) {
1668 (
1669 ReadTarget::Trace(id.to_owned()),
1670 parse_detail_read_flags(
1671 args,
1672 "read trace",
1673 READ_TRACE_NEXT_STEP,
1674 TRACE_DETAIL_UNSUPPORTED_FLAGS,
1675 )?,
1676 )
1677 } else if is_issue_id(id) {
1678 (
1679 ReadTarget::Issue(id.to_owned()),
1680 parse_detail_read_flags(
1681 args,
1682 "read issue",
1683 READ_ISSUE_NEXT_STEP,
1684 ISSUE_DETAIL_UNSUPPORTED_FLAGS,
1685 )?,
1686 )
1687 } else {
1688 return Err(unknown_command(id));
1689 };
1690 let json = flags.is_json();
1691 let options = flags.into_read_options();
1692 validate_read_filters(&target, &options)?;
1693
1694 Ok(Command::Read {
1695 target,
1696 options: Box::new(options),
1697 json,
1698 })
1699}
1700
1701fn validate_read_filters(target: &ReadTarget, filters: &ReadOptions) -> Result<(), CliError> {
1703 let unsupported = match target {
1704 ReadTarget::Logs => filters
1705 .first_log_unsupported_flag()
1706 .map(|flag| (flag, "read logs", READ_LOGS_NEXT_STEP)),
1707 ReadTarget::Issues => filters
1708 .first_issue_list_unsupported_flag()
1709 .map(|flag| (flag, "read issues", READ_ISSUES_NEXT_STEP)),
1710 ReadTarget::Actions => filters
1711 .first_action_unsupported_flag()
1712 .map(|flag| (flag, "read actions", READ_ACTIONS_NEXT_STEP)),
1713 ReadTarget::Releases => filters
1714 .first_release_unsupported_flag()
1715 .map(|flag| (flag, "read releases", READ_RELEASES_NEXT_STEP)),
1716 ReadTarget::Traces => filters
1717 .first_trace_list_unsupported_flag()
1718 .map(|flag| (flag, "read traces", READ_TRACES_NEXT_STEP)),
1719 ReadTarget::Trace(_) => filters
1720 .first_trace_detail_unsupported_flag()
1721 .map(|flag| (flag, "read trace", READ_TRACE_NEXT_STEP)),
1722 ReadTarget::Issue(_) => filters
1723 .first_issue_detail_unsupported_flag()
1724 .map(|flag| (flag, "read issue", READ_ISSUE_NEXT_STEP)),
1725 };
1726
1727 if let Some((flag, command, next)) = unsupported {
1728 return Err(CliError::UnsupportedFlag {
1729 flag: flag.to_owned(),
1730 command,
1731 next,
1732 });
1733 }
1734 match target {
1735 ReadTarget::Logs => validate_read_cursor(filters, CliError::InvalidLogCursor)?,
1736 ReadTarget::Actions => validate_read_cursor(filters, CliError::InvalidActionCursor)?,
1737 ReadTarget::Issues => validate_read_cursor(filters, CliError::InvalidIssueCursor)?,
1738 ReadTarget::Releases | ReadTarget::Traces | ReadTarget::Trace(_) | ReadTarget::Issue(_) => {
1739 }
1740 }
1741 Ok(())
1742}
1743
1744fn validate_read_cursor(
1746 filters: &ReadOptions,
1747 invalid_cursor: fn(String) -> CliError,
1748) -> Result<(), CliError> {
1749 match (
1750 filters.pagination.as_deref(),
1751 filters.cursor_time.as_ref(),
1752 filters.cursor_id.as_ref(),
1753 ) {
1754 (None | Some("cursor"), None, None) | (Some("cursor"), Some(_), Some(_)) => Ok(()),
1755 (None, _, _) => Err(invalid_cursor(String::from(
1756 "cursor fields require --pagination cursor",
1757 ))),
1758 (Some("cursor"), _, _) => Err(invalid_cursor(String::from(
1759 "--cursor-time and --cursor-id must be used together",
1760 ))),
1761 (Some(_), _, _) => Err(CliError::UnknownPagination),
1762 }
1763}
1764
1765fn parse_explain(args: &[String]) -> Result<Command, CliError> {
1767 let (resource, rest) = take_required_position(args, "resource", EXPLAIN_RESOURCE_NEXT_STEP)?;
1768 let (target, tail) = match resource.as_str() {
1769 "issue" => {
1770 let (id, tail) =
1771 take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1772 (ExplainTarget::Issue(id), tail)
1773 }
1774 "trace" => {
1775 let (id, tail) =
1776 take_required_position(rest.as_slice(), "trace_id", "provide a trace id")?;
1777 (ExplainTarget::Trace(id), tail)
1778 }
1779 other => {
1780 if let Some(target) = infer_explain_target(other) {
1781 (target, rest)
1782 } else {
1783 return Err(unknown_resource(other, EXPLAIN_RESOURCE_NEXT_STEP));
1784 }
1785 }
1786 };
1787 let flags = parse_flags(tail.as_slice(), FlagScope::Explain)?;
1788 Ok(Command::Explain {
1789 target,
1790 json: flags.is_json(),
1791 })
1792}
1793
1794fn parse_set(args: &[String]) -> Result<Command, CliError> {
1796 let (resource, rest) = take_required_position(args, "resource", SET_RESOURCE_NEXT_STEP)?;
1797 if resource != "issue" {
1798 return Err(unknown_resource(resource.as_str(), SET_RESOURCE_NEXT_STEP));
1799 }
1800 let (id, rest) = take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1801 let (status, tail) =
1802 take_required_position(rest.as_slice(), "status", ISSUE_STATUS_ARGUMENT_NEXT_STEP)?;
1803 let status = normalize_status(status.as_str())?;
1804 let flags = parse_flags(tail.as_slice(), FlagScope::Set)?;
1805
1806 Ok(Command::Set {
1807 target: SetTarget::IssueStatus { id, status },
1808 json: flags.is_json(),
1809 })
1810}