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, is_uuid};
30use crate::{
31 CliError, Command, ExplainTarget, HelpTopic, ISSUE_STATUS_ARGUMENT_NEXT_STEP,
32 ProjectCreateOptions, ProjectIngestKeyCreateOptions, ProjectSetupSeenOptions, ReadOptions,
33 ReadTarget, SetTarget, 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" | "health" | "ping" => parse_status(tail),
183 "whoami" | "me" => parse_whoami(tail),
184 "doctor" => parse_doctor(tail),
185 "version" => parse_version(tail),
186 "account" if tail.first().is_some_and(|arg| arg == "usage") => parse_usage(&tail[1..]),
187 alias if auth_namespace::is_namespace(alias) => auth_namespace::parse(tail),
188 alias if auth_namespace::is_help_alias(alias) => parse_help_alias(HelpTopic::Auth, tail),
189 "json" | "output" => parse_help_alias(HelpTopic::Json, tail),
190 alias if is_examples_help_alias(alias) => parse_help_alias(HelpTopic::Examples, tail),
191 alias if is_project_help_alias(alias) => parse_project(tail),
192 "usage" => parse_usage(tail),
193 "support" => parse_support(tail),
194 "investigate" => parse_investigate(tail),
195 "debug-artifacts" => parse_native_debug_artifacts(tail),
196 alias if is_direct_filter_help_alias(alias) => parse_help_alias(HelpTopic::Read, tail),
197 "read" => parse_read(tail),
198 alias if is_read_verb(alias) => parse_read_verb(alias, tail),
199 status if is_known_issue_status(status) && has_issue_id_candidate(tail) => {
200 parse_status_first_issue_id_shortcut(status, tail)
201 }
202 status
203 if is_known_issue_status(status) && has_status_first_issue_resource_candidate(tail) =>
204 {
205 parse_status_first_issue_read(status, tail)
206 }
207 status if is_known_issue_status(status) => parse_bare_issue_status_shortcut(status, tail),
208 alias if is_log_search_shortcut(alias) => {
209 parse_search_shortcut(log_search_shortcut_label(alias), tail)
210 }
211 "log" => parse_read_resource("logs", tail),
212 "release" => parse_read_resource("releases", tail),
213 alias if matches!(alias, "trace" | "span") && !has_position_candidate(tail) => {
214 parse_help_alias(HelpTopic::ReadTrace, tail)
215 }
216 alias if matches!(alias, "traces" | "spans") && has_trace_id_candidate(tail) => {
217 parse_read_resource("trace", tail)
218 }
219 "traces" | "spans" => parse_read_resource("traces", tail),
220 "logs" | "issues" | "errors" | "error" | "exceptions" | "exception" | "actions"
221 | "events" | "event" | "action" | "releases" | "trace" | "issue" => {
222 parse_read_resource(head, tail)
223 }
224 "span" if has_position_candidate(tail) => parse_read_resource("trace", tail),
225 "resolve" | "close" | "ignore" | "reopen" => parse_issue_status_shortcut(head, tail),
226 alias if is_watch_command_alias(alias) => parse_watch(tail),
227 "explain" => parse_explain(tail),
228 "set" => parse_set(tail),
229 id if is_pasted_detail_id(id) => parse_pasted_detail_id(id, tail),
230 _ => Err(unknown_command(head)),
231 }
232}
233
234fn parse_native_debug_artifacts(args: &[String]) -> Result<Command, CliError> {
236 let normalized = move_leading_json_to_tail(args);
237 let Some((operation, tail)) = normalized.split_first() else {
238 return Err(CliError::InvalidNativeDebugCommand);
239 };
240 match operation.as_str() {
241 "upload" => parse_native_debug_upload(tail),
242 "lookup" => parse_native_debug_lookup(tail),
243 _ => Err(CliError::InvalidNativeDebugCommand),
244 }
245}
246
247fn parse_native_debug_upload(args: &[String]) -> Result<Command, CliError> {
249 let Some((path, flags)) = args.split_first() else {
250 return Err(CliError::InvalidNativeDebugCommand);
251 };
252 if path.is_empty() || path.chars().any(char::is_control) || path.starts_with('-') {
253 return Err(CliError::InvalidNativeDebugCommand);
254 }
255 let parsed = parse_native_debug_scope(flags, false)?;
256 Ok(Command::NativeDebugArtifacts {
257 target: crate::NativeDebugArtifactsTarget::Upload(crate::NativeDebugUploadOptions {
258 path: path.clone(),
259 project_id: parsed.project_id,
260 release: parsed.release,
261 environment: parsed.environment,
262 service: parsed.service,
263 expected_image_uuids: parsed.expected_image_uuids,
264 dry_run: parsed.dry_run,
265 }),
266 json: parsed.json,
267 })
268}
269
270fn parse_native_debug_lookup(args: &[String]) -> Result<Command, CliError> {
272 let parsed = parse_native_debug_scope(args, true)?;
273 let image_uuid = parsed
274 .image_uuid
275 .and_then(|value| normalize_native_debug_uuid(value.as_str()))
276 .ok_or(CliError::InvalidNativeDebugIdentity)?;
277 let architecture = parsed
278 .architecture
279 .filter(|value| matches!(value.as_str(), "arm64" | "arm64e" | "x86_64"))
280 .ok_or(CliError::InvalidNativeDebugIdentity)?;
281 Ok(Command::NativeDebugArtifacts {
282 target: crate::NativeDebugArtifactsTarget::Lookup(crate::NativeDebugLookupOptions {
283 project_id: parsed.project_id,
284 release: parsed.release,
285 environment: parsed.environment,
286 service: parsed.service,
287 image_uuid,
288 architecture,
289 }),
290 json: parsed.json,
291 })
292}
293
294#[derive(Default)]
296struct NativeDebugScope {
297 project_id: String,
299 release: String,
301 environment: String,
303 service: String,
305 image_uuid: Option<String>,
307 expected_image_uuids: Vec<String>,
309 architecture: Option<String>,
311 dry_run: bool,
313 json: bool,
315}
316
317fn parse_native_debug_scope(args: &[String], lookup: bool) -> Result<NativeDebugScope, CliError> {
319 let mut project_id = None;
320 let mut release = None;
321 let mut environment = None;
322 let mut service = None;
323 let mut image_uuid = None;
324 let mut expected_image_uuids = Vec::new();
325 let mut architecture = None;
326 let mut dry_run = false;
327 let mut json = false;
328 let mut index = 0;
329 while let Some(flag) = args.get(index) {
330 if flag == "--json" {
331 if json {
332 return Err(CliError::InvalidNativeDebugCommand);
333 }
334 json = true;
335 index += 1;
336 continue;
337 }
338 if flag == "--dry-run" && !lookup {
339 if dry_run {
340 return Err(CliError::InvalidNativeDebugCommand);
341 }
342 dry_run = true;
343 index += 1;
344 continue;
345 }
346 if flag == "--expect-image-uuid" && !lookup {
347 let value = args
348 .get(index + 1)
349 .and_then(|value| normalize_native_debug_uuid(value.as_str()))
350 .ok_or(CliError::InvalidNativeDebugIdentity)?;
351 if expected_image_uuids
352 .iter()
353 .any(|existing| existing == &value)
354 {
355 return Err(CliError::InvalidNativeDebugIdentity);
356 }
357 expected_image_uuids.push(value);
358 index += 2;
359 continue;
360 }
361 let destination = match flag.as_str() {
362 "--project" => &mut project_id,
363 "--release" => &mut release,
364 "--environment" => &mut environment,
365 "--service" => &mut service,
366 "--image-uuid" if lookup => &mut image_uuid,
367 "--architecture" if lookup => &mut architecture,
368 _ => return Err(CliError::InvalidNativeDebugCommand),
369 };
370 if destination.is_some() {
371 return Err(CliError::InvalidNativeDebugCommand);
372 }
373 let value = args
374 .get(index + 1)
375 .ok_or(CliError::InvalidNativeDebugCommand)?;
376 *destination = Some(value.clone());
377 index += 2;
378 }
379
380 let project_id = project_id
381 .filter(|value| is_canonical_lower_uuid(value))
382 .ok_or(CliError::InvalidNativeDebugCommand)?;
383 let release = normalize_native_scope(release).ok_or(CliError::InvalidNativeDebugCommand)?;
384 let environment =
385 normalize_native_scope(environment).ok_or(CliError::InvalidNativeDebugCommand)?;
386 let service = normalize_native_scope(service).ok_or(CliError::InvalidNativeDebugCommand)?;
387 if lookup != (image_uuid.is_some() && architecture.is_some()) {
388 return Err(CliError::InvalidNativeDebugCommand);
389 }
390 if lookup && (!expected_image_uuids.is_empty() || dry_run) {
391 return Err(CliError::InvalidNativeDebugCommand);
392 }
393 expected_image_uuids.sort();
394 Ok(NativeDebugScope {
395 project_id,
396 release,
397 environment,
398 service,
399 image_uuid,
400 expected_image_uuids,
401 architecture,
402 dry_run,
403 json,
404 })
405}
406
407fn normalize_native_scope(value: Option<String>) -> Option<String> {
409 let value = value?;
410 let trimmed = value.trim();
411 (!trimmed.is_empty() && trimmed.len() <= 256 && !trimmed.chars().any(char::is_control))
412 .then(|| trimmed.to_owned())
413}
414
415fn normalize_native_debug_uuid(value: &str) -> Option<String> {
417 if value.len() != 36 {
418 return None;
419 }
420 let mut normalized = String::with_capacity(36);
421 for (index, byte) in value.bytes().enumerate() {
422 if matches!(index, 8 | 13 | 18 | 23) {
423 if byte != b'-' {
424 return None;
425 }
426 normalized.push('-');
427 } else if byte.is_ascii_hexdigit() {
428 normalized.push(char::from(byte.to_ascii_lowercase()));
429 } else {
430 return None;
431 }
432 }
433 Some(normalized)
434}
435
436fn is_canonical_lower_uuid(value: &str) -> bool {
438 value.len() == 36
439 && value.bytes().enumerate().all(|(index, byte)| {
440 matches!(index, 8 | 13 | 18 | 23) && byte == b'-'
441 || !matches!(index, 8 | 13 | 18 | 23)
442 && (byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
443 })
444}
445
446fn parse_investigate(args: &[String]) -> Result<Command, CliError> {
448 let normalized = move_leading_json_to_tail(args);
449 match normalized.as_slice() {
450 [resource, issue_id]
451 if resource == "issue" && is_safe_investigation_issue_id(issue_id.as_str()) =>
452 {
453 Ok(Command::InvestigateIssue {
454 issue_id: issue_id.clone(),
455 json: false,
456 })
457 }
458 [resource, issue_id, json]
459 if resource == "issue"
460 && is_safe_investigation_issue_id(issue_id.as_str())
461 && json == "--json" =>
462 {
463 Ok(Command::InvestigateIssue {
464 issue_id: issue_id.clone(),
465 json: true,
466 })
467 }
468 _ => Err(CliError::InvalidInvestigationCommand),
469 }
470}
471
472fn is_safe_investigation_issue_id(value: &str) -> bool {
474 value.len() == 36
475 && value.bytes().enumerate().all(|(index, byte)| {
476 matches!(index, 8 | 13 | 18 | 23) && byte == b'-'
477 || !matches!(index, 8 | 13 | 18 | 23)
478 && (byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
479 })
480}
481
482fn parse_global_json(values: &[String], tail: &[String]) -> Result<Command, CliError> {
484 if global_json_tail_has_duplicate(tail) {
485 return Err(CliError::DuplicateFlag {
486 flag: "--json",
487 next: "use --json once",
488 });
489 }
490 if tail.is_empty() {
491 return Ok(Command::Help {
492 topic: HelpTopic::Root,
493 json: true,
494 });
495 }
496
497 let mut normalized = Vec::with_capacity(values.len());
498 if let Some(program) = values.first() {
499 normalized.push(program.clone());
500 }
501 normalized.extend(tail.iter().cloned());
502 normalized.push(String::from("--json"));
503 parse_values(normalized.as_slice())
504}
505
506fn global_json_tail_has_duplicate(tail: &[String]) -> bool {
508 if !tail.iter().any(|arg| arg == "--json") {
509 return false;
510 }
511 let Some((command, rest)) = tail.split_first() else {
512 return false;
513 };
514 let Some(separator_index) = literal_log_search_separator_index(command, rest) else {
515 return true;
516 };
517 rest[..separator_index].iter().any(|arg| arg == "--json")
518}
519fn unknown_resource(resource: &str, next: &'static str) -> CliError {
521 CliError::UnknownResource {
522 resource: resource.to_owned(),
523 next,
524 }
525}
526
527fn unknown_flag(flag: &str, next: &'static str) -> CliError {
529 CliError::UnknownFlag {
530 flag: flag.to_owned(),
531 next,
532 }
533}
534
535fn unknown_read_resource(resource: &str) -> CliError {
537 unknown_resource(resource, read_resource_next_step(resource))
538}
539
540fn read_resource_next_step(resource: &str) -> &'static str {
542 match resource {
543 "trace" | "traces" | "span" | "spans" => READ_TRACE_ALIAS_NEXT_STEP,
544 _ => READ_RESOURCE_NEXT_STEP,
545 }
546}
547
548fn unknown_command(command: &str) -> CliError {
550 CliError::UnknownCommandName {
551 command: command.to_owned(),
552 next: unknown_command_next_step(command),
553 }
554}
555
556fn unknown_command_next_step(command: &str) -> &'static str {
558 match command {
559 "logg" | "lgs" => "did you mean logbrew logs?",
560 "action" | "event" | "events" => "did you mean logbrew actions?",
561 "releaze" | "rels" => "did you mean logbrew releases?",
562 "statuz" | "stats" => "did you mean logbrew status?",
563 "error" | "errors" | "exception" | "exceptions" => "did you mean logbrew issues?",
564 "trace" | "traces" | "span" | "spans" => TRACE_COMMAND_NEXT_STEP,
565 "env" | "environment" | "environments" => {
566 "use --environment <environment> with logs, issues, actions, releases, or traces"
567 }
568 alias if auth_namespace::is_help_alias(alias) => "run logbrew help auth",
569 _ => HELP_NEXT_STEP,
570 }
571}
572
573fn is_status_help_alias(value: &str) -> bool {
575 matches!(value, "status" | "health" | "ping" | "doctor")
576}
577
578fn is_examples_help_alias(value: &str) -> bool {
580 matches!(
581 value,
582 "example" | "examples" | "sample" | "samples" | "recipe" | "recipes"
583 )
584}
585
586fn is_setup_alias(value: &str) -> bool {
588 matches!(value, "setup" | "init" | "install" | "configure" | "sdk")
589}
590
591fn is_project_help_alias(value: &str) -> bool {
593 matches!(value, "project" | "projects")
594}
595
596fn is_watch_command_alias(value: &str) -> bool {
598 matches!(value, "watch" | "tail" | "follow" | "stream")
599}
600
601fn is_version_flag(value: &str) -> bool {
603 matches!(value, "--version" | "-V")
604}
605
606fn parse_login(args: &[String]) -> Result<Command, CliError> {
608 let flags = parse_flags(args, FlagScope::Login)?;
609 let json = flags.is_json();
610 Ok(Command::Login {
611 provider: flags.login_provider(),
612 open_browser: flags.should_open_browser() && !json,
613 json,
614 })
615}
616
617fn parse_logout(args: &[String]) -> Result<Command, CliError> {
619 let flags = parse_flags(args, FlagScope::Logout)?;
620 Ok(Command::Logout {
621 json: flags.is_json(),
622 })
623}
624
625fn parse_setup(args: &[String]) -> Result<Command, CliError> {
627 if args.iter().any(|arg| arg == "--create-project") {
628 return parse_setup_create_project(args);
629 }
630 let flags = parse_flags(args, FlagScope::Setup)?;
631 Ok(Command::Setup {
632 auto: flags.is_auto(),
633 yes: flags.skip_prompts(),
634 json: flags.is_json(),
635 })
636}
637
638fn parse_setup_create_project(args: &[String]) -> Result<Command, CliError> {
640 let mut seen_create_project = false;
641 let mut seen_json = false;
642
643 for arg in args {
644 match arg.as_str() {
645 "--create-project" => {
646 if std::mem::replace(&mut seen_create_project, true) {
647 return Err(CliError::DuplicateFlag {
648 flag: "--create-project",
649 next: "use --create-project once",
650 });
651 }
652 }
653 "--json" => {
654 if std::mem::replace(&mut seen_json, true) {
655 return Err(CliError::DuplicateFlag {
656 flag: "--json",
657 next: "use --json once",
658 });
659 }
660 }
661 "--help" | "-h" => {}
662 flag if flag.starts_with('-') => {
663 return Err(unknown_flag(flag, PROJECTS_NEXT_STEP));
664 }
665 argument => {
666 return Err(CliError::UnexpectedArgument {
667 argument: argument.to_owned(),
668 command: "setup",
669 next: PROJECTS_NEXT_STEP,
670 });
671 }
672 }
673 }
674
675 Ok(Command::Help {
676 topic: HelpTopic::Projects,
677 json: seen_json,
678 })
679}
680
681fn parse_project(args: &[String]) -> Result<Command, CliError> {
683 let normalized = move_leading_json_to_tail(args);
684 if let Some((namespace, tail)) = normalized.split_first()
685 && matches!(namespace.as_str(), "key" | "keys")
686 {
687 let Some((subcommand, create_args)) = tail.split_first() else {
688 return Err(CliError::InvalidProjectsCommand);
689 };
690 if subcommand == "create" {
691 return parse_project_ingest_key_create(create_args);
692 }
693 return Err(CliError::InvalidProjectsCommand);
694 }
695 if let Some((subcommand, tail)) = normalized.split_first()
696 && subcommand == "create"
697 {
698 return parse_project_create(tail);
699 }
700 if let Some((subcommand, tail)) = normalized.split_first()
701 && subcommand == "archive"
702 {
703 return parse_project_archive(tail);
704 }
705 if let Some((subcommand, tail)) = normalized.split_first()
706 && subcommand == "setup"
707 && has_position_candidate(tail)
708 {
709 return parse_project_setup_seen(tail);
710 }
711 match normalized.as_slice() {
712 [] => Ok(Command::Projects { json: false }),
713 [flag] if flag == "--json" => Ok(Command::Projects { json: true }),
714 [_, ..] => Err(CliError::InvalidProjectsCommand),
715 }
716}
717
718fn parse_project_ingest_key_create(args: &[String]) -> Result<Command, CliError> {
720 let Some((project_id, tail)) = args.split_first() else {
721 return Err(CliError::InvalidProjectIngestKeyCreateCommand);
722 };
723 if !is_uuid(project_id) {
724 return Err(CliError::InvalidProjectIngestKeyCreateCommand);
725 }
726 let mut label = None;
727 let mut kind = None;
728 let mut ingest_key_file = None;
729 let mut abandon_retry = false;
730 let mut json = false;
731 let mut index = 0;
732
733 while let Some(argument) = tail.get(index) {
734 let (flag, inline_value) = argument
735 .split_once('=')
736 .map_or((argument.as_str(), None), |(flag, value)| {
737 (flag, Some(value))
738 });
739 match flag {
740 "--label" if label.is_none() => {
741 let value = project_ingest_key_create_flag_value(tail, &mut index, inline_value)?;
742 label = Some(
743 bounded_project_create_value(value, 120, false)
744 .ok_or(CliError::InvalidProjectIngestKeyCreateCommand)?,
745 );
746 }
747 "--kind" if kind.is_none() => {
748 let value = project_ingest_key_create_flag_value(tail, &mut index, inline_value)?;
749 kind = Some(match value.trim() {
750 "sdk" => String::from("sdk"),
751 "browser" => String::from("browser"),
752 "server" => String::from("server"),
753 "cli" => String::from("cli"),
754 _ => return Err(CliError::InvalidProjectIngestKeyCreateCommand),
755 });
756 }
757 "--ingest-key-file" if ingest_key_file.is_none() => {
758 let value = project_ingest_key_create_flag_value(tail, &mut index, inline_value)?;
759 let trimmed = value.trim();
760 if trimmed.is_empty()
761 || trimmed.len() > 4096
762 || trimmed.chars().any(char::is_control)
763 {
764 return Err(CliError::InvalidProjectIngestKeyCreateCommand);
765 }
766 ingest_key_file = Some(trimmed.to_owned());
767 }
768 "--abandon-retry" if inline_value.is_none() && !abandon_retry => {
769 abandon_retry = true;
770 }
771 "--json" if inline_value.is_none() && !json => json = true,
772 _ => return Err(CliError::InvalidProjectIngestKeyCreateCommand),
773 }
774 index += 1;
775 }
776
777 Ok(Command::ProjectIngestKeyCreate {
778 options: ProjectIngestKeyCreateOptions {
779 project_id: project_id.to_ascii_lowercase(),
780 label: label.unwrap_or_else(|| String::from("CLI-created SDK key")),
781 kind: kind.unwrap_or_else(|| String::from("sdk")),
782 ingest_key_file: ingest_key_file
783 .ok_or(CliError::InvalidProjectIngestKeyCreateCommand)?,
784 abandon_retry,
785 },
786 json,
787 })
788}
789
790fn project_ingest_key_create_flag_value<'a>(
792 args: &'a [String],
793 index: &mut usize,
794 inline: Option<&'a str>,
795) -> Result<&'a str, CliError> {
796 if let Some(value) = inline {
797 return Ok(value);
798 }
799 *index += 1;
800 args.get(*index)
801 .map(String::as_str)
802 .filter(|value| !value.starts_with('-'))
803 .ok_or(CliError::InvalidProjectIngestKeyCreateCommand)
804}
805
806fn parse_project_archive(args: &[String]) -> Result<Command, CliError> {
808 let normalized = move_leading_json_to_tail(args);
809 let Some((project_id, flags)) = normalized.split_first() else {
810 return Err(CliError::InvalidProjectArchiveCommand);
811 };
812 if !is_uuid(project_id) {
813 return Err(CliError::InvalidProjectArchiveCommand);
814 }
815 let mut yes = false;
816 let mut json = false;
817 for flag in flags {
818 match flag.as_str() {
819 "--yes" if !yes => yes = true,
820 "--json" if !json => json = true,
821 _ => return Err(CliError::InvalidProjectArchiveCommand),
822 }
823 }
824 if !yes {
825 return Err(CliError::InvalidProjectArchiveCommand);
826 }
827 Ok(Command::ProjectArchive {
828 project_id: project_id.to_ascii_lowercase(),
829 json,
830 })
831}
832
833fn parse_project_create(args: &[String]) -> Result<Command, CliError> {
835 let Some((name, tail)) = args.split_first() else {
836 return Err(CliError::InvalidProjectCreateCommand);
837 };
838 if name.starts_with('-') {
839 return Err(CliError::InvalidProjectCreateCommand);
840 }
841 let name = bounded_project_create_value(name, 120, false)
842 .ok_or(CliError::InvalidProjectCreateCommand)?;
843 if name.starts_with('-') {
844 return Err(CliError::InvalidProjectCreateCommand);
845 }
846 let mut runtime = None;
847 let mut environment = None;
848 let mut ingest_key_file = None;
849 let mut abandon_retry = false;
850 let mut json = false;
851 let mut index = 0;
852
853 while let Some(argument) = tail.get(index) {
854 let (flag, inline_value) = argument
855 .split_once('=')
856 .map_or((argument.as_str(), None), |(flag, value)| {
857 (flag, Some(value))
858 });
859 match flag {
860 "--runtime" if runtime.is_none() => {
861 let value = project_create_flag_value(tail, &mut index, inline_value)?;
862 runtime = optional_project_create_value(value, 64)?;
863 }
864 "--environment" if environment.is_none() => {
865 let value = project_create_flag_value(tail, &mut index, inline_value)?;
866 environment = optional_project_create_value(value, 64)?;
867 }
868 "--ingest-key-file" if ingest_key_file.is_none() => {
869 let value = project_create_flag_value(tail, &mut index, inline_value)?;
870 let trimmed = value.trim();
871 if trimmed.is_empty()
872 || trimmed.len() > 4096
873 || trimmed.chars().any(char::is_control)
874 {
875 return Err(CliError::InvalidProjectCreateCommand);
876 }
877 ingest_key_file = Some(trimmed.to_owned());
878 }
879 "--abandon-retry" if inline_value.is_none() && !abandon_retry => {
880 abandon_retry = true;
881 }
882 "--json" if inline_value.is_none() && !json => json = true,
883 _ => return Err(CliError::InvalidProjectCreateCommand),
884 }
885 index += 1;
886 }
887
888 let ingest_key_file = ingest_key_file.ok_or(CliError::InvalidProjectCreateCommand)?;
889 Ok(Command::ProjectCreate {
890 options: ProjectCreateOptions {
891 name,
892 runtime,
893 environment,
894 ingest_key_file,
895 abandon_retry,
896 },
897 json,
898 })
899}
900
901fn project_create_flag_value<'a>(
903 args: &'a [String],
904 index: &mut usize,
905 inline: Option<&'a str>,
906) -> Result<&'a str, CliError> {
907 if let Some(value) = inline {
908 return Ok(value);
909 }
910 *index += 1;
911 args.get(*index)
912 .map(String::as_str)
913 .filter(|value| !value.starts_with('-'))
914 .ok_or(CliError::InvalidProjectCreateCommand)
915}
916
917fn bounded_project_create_value(value: &str, limit: usize, allow_blank: bool) -> Option<String> {
919 let value = value.trim();
920 let length = value.chars().count();
921 if value.chars().any(char::is_control) || length > limit || (!allow_blank && length == 0) {
922 return None;
923 }
924 (!value.is_empty()).then(|| value.to_owned())
925}
926
927fn optional_project_create_value(value: &str, limit: usize) -> Result<Option<String>, CliError> {
929 let trimmed = value.trim();
930 if trimmed.is_empty() {
931 return Ok(None);
932 }
933 bounded_project_create_value(trimmed, limit, false)
934 .map(Some)
935 .ok_or(CliError::InvalidProjectCreateCommand)
936}
937
938fn parse_project_setup_seen(args: &[String]) -> Result<Command, CliError> {
940 let (project_id, tail) =
941 take_required_position(args, "project_id", PROJECT_SETUP_SEEN_NEXT_STEP)?;
942 let (options, json) = parse_project_setup_seen_flags(tail.as_slice())?;
943 Ok(Command::ProjectSetupSeen {
944 project_id,
945 options,
946 json,
947 })
948}
949
950fn parse_project_setup_seen_flags(
952 args: &[String],
953) -> Result<(ProjectSetupSeenOptions, bool), CliError> {
954 let mut options = ProjectSetupSeenOptions::default();
955 let mut json = false;
956 let mut seen = Vec::new();
957 let mut index = 0;
958
959 while let Some(arg) = args.get(index) {
960 let (flag, inline_value) = split_project_setup_seen_inline_value(arg.as_str());
961 match flag {
962 "--json" if inline_value.is_none() => {
963 mark_project_setup_seen_flag(&mut seen, "--json")?;
964 json = true;
965 }
966 "--runtime" => {
967 mark_project_setup_seen_flag(&mut seen, "--runtime")?;
968 options.runtime = Some(project_setup_seen_flag_value(
969 args,
970 &mut index,
971 "--runtime",
972 inline_value,
973 )?);
974 }
975 "--source" => {
976 mark_project_setup_seen_flag(&mut seen, "--source")?;
977 options.source = Some(validate_project_setup_seen_source(
978 project_setup_seen_flag_value(args, &mut index, "--source", inline_value)?
979 .as_str(),
980 )?);
981 }
982 "--environment" | "--env" => {
983 mark_project_setup_seen_flag(&mut seen, "--environment")?;
984 let visible_flag = if flag == "--env" {
985 "--env"
986 } else {
987 "--environment"
988 };
989 options.environment = Some(project_setup_seen_flag_value(
990 args,
991 &mut index,
992 visible_flag,
993 inline_value,
994 )?);
995 }
996 flag if flag.starts_with('-') => {
997 return Err(unknown_flag(flag, PROJECT_SETUP_SEEN_NEXT_STEP));
998 }
999 argument => {
1000 return Err(CliError::UnexpectedArgument {
1001 argument: argument.to_owned(),
1002 command: "projects setup",
1003 next: PROJECT_SETUP_SEEN_NEXT_STEP,
1004 });
1005 }
1006 }
1007 index += 1;
1008 }
1009
1010 Ok((options, json))
1011}
1012
1013fn split_project_setup_seen_inline_value(flag: &str) -> (&str, Option<&str>) {
1015 flag.split_once('=')
1016 .map_or((flag, None), |(name, value)| (name, Some(value)))
1017}
1018
1019fn mark_project_setup_seen_flag(
1021 seen: &mut Vec<&'static str>,
1022 flag: &'static str,
1023) -> Result<(), CliError> {
1024 if seen.contains(&flag) {
1025 return Err(CliError::DuplicateFlag {
1026 flag,
1027 next: project_setup_seen_duplicate_next(flag),
1028 });
1029 }
1030 seen.push(flag);
1031 Ok(())
1032}
1033
1034fn project_setup_seen_duplicate_next(flag: &'static str) -> &'static str {
1036 match flag {
1037 "--json" => "use --json once",
1038 "--runtime" => "use --runtime once",
1039 "--source" => "use --source once",
1040 "--environment" => "use --environment once",
1041 _ => "use the flag once",
1042 }
1043}
1044
1045fn project_setup_seen_flag_value(
1047 args: &[String],
1048 index: &mut usize,
1049 flag: &'static str,
1050 inline_value: Option<&str>,
1051) -> Result<String, CliError> {
1052 if let Some(value) = inline_value {
1053 if value.is_empty() {
1054 return Err(missing_project_setup_seen_flag_value(flag));
1055 }
1056 return Ok(value.to_owned());
1057 }
1058 *index += 1;
1059 let Some(value) = args.get(*index) else {
1060 return Err(missing_project_setup_seen_flag_value(flag));
1061 };
1062 if value.starts_with('-') {
1063 return Err(missing_project_setup_seen_flag_value(flag));
1064 }
1065 Ok(value.clone())
1066}
1067
1068fn missing_project_setup_seen_flag_value(flag: &'static str) -> CliError {
1070 CliError::MissingFlagValue {
1071 flag,
1072 next: project_setup_seen_missing_value_next(flag),
1073 }
1074}
1075
1076fn project_setup_seen_missing_value_next(flag: &'static str) -> &'static str {
1078 match flag {
1079 "--runtime" => "provide a value after --runtime",
1080 "--source" => PROJECT_SETUP_SOURCE_NEXT_STEP,
1081 "--environment" => "provide a value after --environment",
1082 "--env" => "provide a value after --env",
1083 _ => "provide a value after the flag",
1084 }
1085}
1086
1087fn validate_project_setup_seen_source(source: &str) -> Result<String, CliError> {
1089 match source {
1090 "api" | "cli" | "sdk" => Ok(source.to_owned()),
1091 other => Err(CliError::InvalidSetupSource(other.to_owned())),
1092 }
1093}
1094
1095fn parse_status(args: &[String]) -> Result<Command, CliError> {
1097 let flags = parse_flags(args, FlagScope::Status)?;
1098 Ok(Command::Status {
1099 json: flags.is_json(),
1100 })
1101}
1102
1103fn parse_whoami(args: &[String]) -> Result<Command, CliError> {
1105 let flags = parse_flags(args, FlagScope::WhoAmI)?;
1106 Ok(Command::WhoAmI {
1107 json: flags.is_json(),
1108 })
1109}
1110
1111fn parse_usage(args: &[String]) -> Result<Command, CliError> {
1113 match args {
1114 [] => Ok(Command::Usage { json: false }),
1115 [flag] if flag == "--json" => Ok(Command::Usage { json: true }),
1116 _ => Err(CliError::InvalidUsageCommand),
1117 }
1118}
1119
1120fn parse_doctor(args: &[String]) -> Result<Command, CliError> {
1122 if args.iter().all(|arg| arg == "--json") {
1123 return parse_status(args);
1124 }
1125
1126 let mut project_id = None;
1127 let mut json = false;
1128 let mut index = 0;
1129 while let Some(argument) = args.get(index) {
1130 if let Some(value) = argument
1131 .strip_prefix("--project=")
1132 .or_else(|| argument.strip_prefix("--project-id="))
1133 {
1134 if project_id.is_some() || !is_uuid(value) {
1135 return Err(CliError::InvalidDoctorCommand);
1136 }
1137 project_id = Some(value.to_owned());
1138 index += 1;
1139 continue;
1140 }
1141 match argument.as_str() {
1142 "--json" if !json => json = true,
1143 "--project" | "--project-id" if project_id.is_none() => {
1144 index += 1;
1145 let Some(value) = args.get(index) else {
1146 return Err(CliError::InvalidDoctorCommand);
1147 };
1148 if !is_uuid(value) {
1149 return Err(CliError::InvalidDoctorCommand);
1150 }
1151 project_id = Some(value.clone());
1152 }
1153 _ => return Err(CliError::InvalidDoctorCommand),
1154 }
1155 index += 1;
1156 }
1157
1158 project_id.map_or(Err(CliError::InvalidDoctorCommand), |project_id| {
1159 Ok(Command::Doctor { project_id, json })
1160 })
1161}
1162
1163fn parse_version(args: &[String]) -> Result<Command, CliError> {
1165 let flags = parse_flags(args, FlagScope::Version)?;
1166 Ok(Command::Version {
1167 json: flags.is_json(),
1168 })
1169}
1170
1171fn take_required_arg<'a>(
1173 args: &'a [String],
1174 argument: &'static str,
1175 next: &'static str,
1176) -> Result<(&'a str, &'a [String]), CliError> {
1177 let Some((value, rest)) = args.split_first() else {
1178 return Err(CliError::MissingArgument { argument, next });
1179 };
1180 if value.starts_with('-') {
1181 return Err(CliError::MissingArgument { argument, next });
1182 }
1183 Ok((value.as_str(), rest))
1184}
1185
1186fn move_leading_json_to_tail(args: &[String]) -> Vec<String> {
1188 if args.first().is_some_and(|arg| arg == "--json") {
1189 let mut normalized = Vec::with_capacity(args.len());
1190 normalized.extend(args[1..].iter().cloned());
1191 normalized.push(String::from("--json"));
1192 normalized
1193 } else {
1194 args.to_vec()
1195 }
1196}
1197
1198fn has_position_candidate(args: &[String]) -> bool {
1200 move_leading_json_to_tail(args)
1201 .first()
1202 .is_some_and(|arg| !arg.starts_with('-'))
1203}
1204
1205fn has_trace_id_candidate(args: &[String]) -> bool {
1207 move_leading_json_to_tail(args)
1208 .first()
1209 .is_some_and(|arg| is_trace_id(arg))
1210}
1211
1212fn take_required_position(
1214 args: &[String],
1215 argument: &'static str,
1216 next: &'static str,
1217) -> Result<(String, Vec<String>), CliError> {
1218 let normalized = move_leading_json_to_tail(args);
1219 let (value, rest) = take_required_arg(normalized.as_slice(), argument, next)?;
1220 Ok((value.to_owned(), rest.to_vec()))
1221}
1222
1223fn parse_read(args: &[String]) -> Result<Command, CliError> {
1225 let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
1226 let resource = normalize_read_resource(resource.as_str());
1227 if is_recency_read_verb(resource) {
1228 return parse_read_verb(resource, rest.as_slice());
1229 }
1230 if is_known_issue_status(resource) && has_status_first_issue_resource_candidate(rest.as_slice())
1231 {
1232 return parse_status_first_issue_read(resource, rest.as_slice());
1233 }
1234 parse_read_resource(resource, rest.as_slice())
1235}
1236
1237fn normalize_read_resource(resource: &str) -> &str {
1239 match resource {
1240 "log" => "logs",
1241 "release" => "releases",
1242 _ => resource,
1243 }
1244}
1245
1246fn parse_read_verb(verb: &str, args: &[String]) -> Result<Command, CliError> {
1248 let rewritten_args = recency_count_shortcut_args(verb, args);
1249 let args = rewritten_args.as_deref().unwrap_or(args);
1250 let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
1251 if is_known_issue_status(resource.as_str())
1252 && has_status_first_issue_resource_candidate(rest.as_slice())
1253 {
1254 return parse_status_first_issue_read(resource.as_str(), rest.as_slice());
1255 }
1256 let resource = normalize_read_verb_resource(verb, resource.as_str());
1257 parse_read_resource(resource, rest.as_slice())
1258}
1259
1260fn recency_count_shortcut_args(verb: &str, args: &[String]) -> Option<Vec<String>> {
1262 if !is_recency_read_verb(verb) {
1263 return None;
1264 }
1265 let normalized = move_leading_json_to_tail(args);
1266 let (count, tail) = normalized.split_first().filter(|(count, tail)| {
1267 !tail.is_empty() && count.chars().all(|char| char.is_ascii_digit())
1268 })?;
1269 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1270 rewritten.push(tail[0].clone());
1271 let rest = &tail[1..];
1272 if let Some(separator_index) = rest.iter().position(|arg| arg == "--") {
1273 rewritten.extend(rest[..separator_index].iter().cloned());
1274 rewritten.push(String::from("--limit"));
1275 rewritten.push(count.clone());
1276 rewritten.extend(rest[separator_index..].iter().cloned());
1277 return Some(rewritten);
1278 }
1279 rewritten.extend(rest.iter().cloned());
1280 rewritten.push(String::from("--limit"));
1281 rewritten.push(count.clone());
1282 Some(rewritten)
1283}
1284
1285fn is_read_verb(value: &str) -> bool {
1287 matches!(value, "show" | "list" | "get") || is_recency_read_verb(value)
1288}
1289
1290fn is_recency_read_verb(value: &str) -> bool {
1292 matches!(value, "latest" | "recent" | "last" | "newest")
1293}
1294
1295fn normalize_read_verb_resource<'a>(verb: &str, resource: &'a str) -> &'a str {
1297 match (verb, resource) {
1298 ("list" | "show" | "get", "log") => "logs",
1299 (alias, "log") if is_recency_read_verb(alias) => "logs",
1300 (alias, "issue") if is_recency_read_verb(alias) => "issues",
1301 ("list", "issue") => "issues",
1302 ("list" | "show" | "get", "release") => "releases",
1303 (alias, "release") if is_recency_read_verb(alias) => "releases",
1304 _ => resource,
1305 }
1306}
1307
1308fn is_log_search_shortcut(command: &str) -> bool {
1310 matches!(command, "search" | "find" | "grep")
1311}
1312
1313fn is_log_search_separator_literal(command: &str, args: &[String]) -> bool {
1315 literal_log_search_separator_index(command, args).is_some()
1316}
1317
1318fn log_search_shortcut_label(command: &str) -> &'static str {
1320 match command {
1321 "find" => "find",
1322 "grep" => "grep",
1323 _ => "search",
1324 }
1325}
1326
1327fn parse_search_shortcut(label: &'static str, args: &[String]) -> Result<Command, CliError> {
1329 let (query, tail) = take_search_query(args, label)?;
1330 let mut rest = Vec::with_capacity(tail.len() + 2);
1331 if query.starts_with('-') {
1332 rest.push(format!("--search={query}"));
1333 } else {
1334 rest.push(String::from("--search"));
1335 rest.push(query);
1336 }
1337 rest.extend(tail);
1338 parse_read_resource("logs", rest.as_slice())
1339}
1340
1341fn take_search_query(
1343 args: &[String],
1344 argument: &'static str,
1345) -> Result<(String, Vec<String>), CliError> {
1346 let normalized = move_leading_json_to_tail(args);
1347 if normalized.first().is_some_and(|arg| arg == "--") {
1348 return take_separator_search_query(normalized.as_slice(), argument);
1349 }
1350 let query_word_count = normalized
1351 .iter()
1352 .take_while(|arg| !arg.starts_with('-'))
1353 .count();
1354 if query_word_count == 0 {
1355 return Err(CliError::MissingArgument {
1356 argument,
1357 next: SEARCH_NEXT_STEP,
1358 });
1359 }
1360 let query = normalized[..query_word_count].join(" ");
1361 let tail = normalized[query_word_count..].to_vec();
1362 Ok((query, tail))
1363}
1364
1365fn take_separator_search_query(
1367 args: &[String],
1368 argument: &'static str,
1369) -> Result<(String, Vec<String>), CliError> {
1370 let words = &args[1..];
1371 if words.is_empty() {
1372 return Err(CliError::MissingArgument {
1373 argument,
1374 next: SEARCH_NEXT_STEP,
1375 });
1376 }
1377 let has_trailing_json_mode = words.len() > 1 && words.last().is_some_and(|arg| arg == "--json");
1378 let query_end = if has_trailing_json_mode {
1379 words.len() - 1
1380 } else {
1381 words.len()
1382 };
1383 let query = words[..query_end].join(" ");
1384 let tail = if has_trailing_json_mode {
1385 vec![String::from("--json")]
1386 } else {
1387 Vec::new()
1388 };
1389 Ok((query, tail))
1390}
1391
1392fn parse_read_resource(resource: &str, rest: &[String]) -> Result<Command, CliError> {
1394 let (target, flags) = match resource {
1395 "logs" => parse_log_list_read(rest)?,
1396 alias if is_issue_collection_alias(alias) && has_issue_id_candidate(rest) => {
1397 return parse_issue_detail_or_status(rest);
1398 }
1399 alias if is_issue_collection_alias(alias) => parse_issue_list_read(rest)?,
1400 alias if is_action_collection_alias(alias) => parse_action_list_read(rest)?,
1401 "releases" => parse_list_read(
1402 ReadTarget::Releases,
1403 rest,
1404 "read releases",
1405 READ_RELEASES_NEXT_STEP,
1406 &[
1407 "--name",
1408 "--user",
1409 "--distinct-id",
1410 "--trace",
1411 "--trace-id",
1412 "--level",
1413 "--severity",
1414 "--search",
1415 "--status",
1416 "--min-duration-ms",
1417 ],
1418 )?,
1419 "traces" | "spans" if has_trace_id_candidate(rest) => {
1420 return parse_trace_detail_or_explain(rest);
1421 }
1422 "traces" | "spans" => parse_trace_list_read(rest)?,
1423 "trace" => return parse_trace_detail_or_explain(rest),
1424 "span" if has_position_candidate(rest) => {
1425 return parse_trace_detail_or_explain(rest);
1426 }
1427 "issue" if has_issue_status_candidate(rest) => parse_issue_list_read(rest)?,
1428 "issue" => return parse_issue_detail_or_status(rest),
1429 other => return Err(unknown_read_resource(other)),
1430 };
1431 let json = flags.is_json();
1432 let options = flags.into_read_options();
1433 validate_read_filters(&target, &options)?;
1434
1435 Ok(Command::Read {
1436 target,
1437 options: Box::new(options),
1438 json,
1439 })
1440}
1441
1442fn is_issue_collection_alias(value: &str) -> bool {
1444 matches!(
1445 value,
1446 "issues" | "errors" | "error" | "exceptions" | "exception"
1447 )
1448}
1449
1450fn is_status_first_issue_collection_alias(value: &str) -> bool {
1452 value == "issue" || is_issue_collection_alias(value)
1453}
1454
1455fn has_status_first_issue_resource_candidate(args: &[String]) -> bool {
1457 move_leading_json_to_tail(args)
1458 .first()
1459 .is_some_and(|arg| is_status_first_issue_collection_alias(arg))
1460}
1461
1462fn has_issue_status_candidate(args: &[String]) -> bool {
1464 move_leading_json_to_tail(args)
1465 .first()
1466 .is_some_and(|arg| is_known_issue_status(arg))
1467}
1468
1469fn is_action_collection_alias(value: &str) -> bool {
1471 matches!(value, "actions" | "events" | "event" | "action")
1472}
1473
1474fn parse_log_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1476 let args = log_shortcut_args(rest);
1477 parse_list_read(
1478 ReadTarget::Logs,
1479 args.as_slice(),
1480 "read logs",
1481 READ_LOGS_NEXT_STEP,
1482 &[
1483 "--name",
1484 "--user",
1485 "--distinct-id",
1486 "--status",
1487 "--min-duration-ms",
1488 ],
1489 )
1490}
1491
1492fn parse_issue_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1494 let args = issue_status_shortcut_args(rest);
1495 parse_list_read(
1496 ReadTarget::Issues,
1497 args.as_slice(),
1498 "read issues",
1499 READ_ISSUES_NEXT_STEP,
1500 &[
1501 "--name",
1502 "--user",
1503 "--distinct-id",
1504 "--trace",
1505 "--trace-id",
1506 "--level",
1507 "--severity",
1508 "--search",
1509 "--min-duration-ms",
1510 ],
1511 )
1512}
1513
1514fn parse_status_first_issue_read(status: &str, args: &[String]) -> Result<Command, CliError> {
1516 let canonical_status = normalize_status(status)?;
1517 let (resource, rest) = take_required_position(args, "resource", READ_ISSUES_NEXT_STEP)?;
1518 let resource = resource.as_str();
1519 if !is_status_first_issue_collection_alias(resource) {
1520 return Err(unknown_read_resource(resource));
1521 }
1522 let resource = if resource == "issue" {
1523 "issues"
1524 } else {
1525 resource
1526 };
1527 let mut rewritten = Vec::with_capacity(rest.len() + 2);
1528 rewritten.push(String::from("--status"));
1529 rewritten.push(canonical_status);
1530 rewritten.extend(rest);
1531 parse_read_resource(resource, rewritten.as_slice())
1532}
1533
1534fn issue_status_shortcut_args(args: &[String]) -> Vec<String> {
1536 let normalized = move_leading_json_to_tail(args);
1537 let Some((status, tail)) = normalized
1538 .split_first()
1539 .and_then(|(status, tail)| normalize_status(status).ok().map(|value| (value, tail)))
1540 else {
1541 return args.to_vec();
1542 };
1543 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1544 rewritten.push(String::from("--status"));
1545 rewritten.push(status);
1546 rewritten.extend(tail.iter().cloned());
1547 rewritten
1548}
1549
1550fn parse_action_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1552 let args = action_name_shortcut_args(rest);
1553 parse_list_read(
1554 ReadTarget::Actions,
1555 args.as_slice(),
1556 "read actions",
1557 READ_ACTIONS_NEXT_STEP,
1558 ACTION_LIST_UNSUPPORTED_FLAGS,
1559 )
1560}
1561
1562fn action_name_shortcut_args(args: &[String]) -> Vec<String> {
1564 let normalized = move_leading_json_to_tail(args);
1565 let Some((name, tail)) = normalized
1566 .split_first()
1567 .filter(|(name, _)| !name.starts_with('-') && !is_read_filter_word(name))
1568 else {
1569 return args.to_vec();
1570 };
1571 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1572 rewritten.push(String::from("--name"));
1573 rewritten.push(name.clone());
1574 rewritten.extend(tail.iter().cloned());
1575 rewritten
1576}
1577
1578fn has_issue_id_candidate(args: &[String]) -> bool {
1580 move_leading_json_to_tail(args)
1581 .first()
1582 .is_some_and(|arg| is_issue_id(arg))
1583}
1584
1585fn parse_issue_detail_or_status(args: &[String]) -> Result<Command, CliError> {
1587 let (id, tail) = take_required_position(args, "issue_id", "provide an issue id")?;
1588 if has_issue_status_action(tail.as_slice()) {
1589 return parse_issue_first_status_shortcut(id, tail.as_slice());
1590 }
1591 if let Some(command) =
1592 parse_detail_explain_suffix(ExplainTarget::Issue(id.clone()), tail.as_slice())?
1593 {
1594 return Ok(command);
1595 }
1596 let target = ReadTarget::Issue(id);
1597 let flags = parse_detail_read_flags(
1598 tail.as_slice(),
1599 "read issue",
1600 READ_ISSUE_NEXT_STEP,
1601 ISSUE_DETAIL_UNSUPPORTED_FLAGS,
1602 )?;
1603 let json = flags.is_json();
1604 let options = flags.into_read_options();
1605 validate_read_filters(&target, &options)?;
1606
1607 Ok(Command::Read {
1608 target,
1609 options: Box::new(options),
1610 json,
1611 })
1612}
1613
1614fn parse_list_read(
1616 target: ReadTarget,
1617 args: &[String],
1618 command: &'static str,
1619 next: &'static str,
1620 unsupported_flags: &[&str],
1621) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1622 reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
1623 Ok((target, parse_flags(args, FlagScope::Read)?))
1624}
1625
1626fn reject_unsupported_read_flags(
1628 args: &[String],
1629 command: &'static str,
1630 next: &'static str,
1631 unsupported_flags: &[&str],
1632) -> Result<(), CliError> {
1633 let mut index = 0;
1634 let mut seen = Vec::new();
1635 while let Some(arg) = args.get(index) {
1636 let (flag, inline_value) = arg
1637 .split_once('=')
1638 .map_or((arg.as_str(), None), |(name, value)| (name, Some(value)));
1639 if !is_read_value_flag(flag) {
1640 if flag == "--json" && inline_value.is_none() {
1641 if seen.contains(&"--json") {
1642 return Ok(());
1643 }
1644 seen.push("--json");
1645 index += 1;
1646 continue;
1647 }
1648 if inline_value.is_some() && is_simple_flag(flag) {
1649 return Err(CliError::UnsupportedFlag {
1650 flag: arg.to_owned(),
1651 command,
1652 next,
1653 });
1654 }
1655 if arg.starts_with('-') {
1656 return Err(unknown_flag(arg, next));
1657 }
1658 return Ok(());
1659 }
1660 if unsupported_flags.contains(&flag) {
1661 return Err(CliError::UnsupportedFlag {
1662 flag: user_facing_read_flag(flag).to_owned(),
1663 command,
1664 next,
1665 });
1666 }
1667 if let Some(canonical) = read_value_canonical_flag(flag) {
1668 if seen.contains(&canonical) {
1669 return Ok(());
1670 }
1671 seen.push(canonical);
1672 }
1673 if inline_value.is_some_and(str::is_empty) {
1674 return Ok(());
1675 }
1676 if inline_value.is_some_and(|value| has_invalid_supported_read_value(flag, value)) {
1677 return Ok(());
1678 }
1679 if inline_value.is_none() {
1680 let Some(value) = args.get(index + 1) else {
1681 return Ok(());
1682 };
1683 if value.starts_with('-') {
1684 return Ok(());
1685 }
1686 if has_invalid_supported_read_value(flag, value) {
1687 return Ok(());
1688 }
1689 index += 1;
1690 }
1691 index += 1;
1692 }
1693 Ok(())
1694}
1695
1696fn read_value_canonical_flag(flag: &str) -> Option<&'static str> {
1698 let canonical = match flag {
1699 "--name" => "--name",
1700 "--service" | "--service-name" => "--service",
1701 "--since" => "--since",
1702 "--user" | "--distinct-id" => "--user",
1703 "--trace" | "--trace-id" => "--trace",
1704 "--level" | "--severity" => "--severity",
1705 "--search" => "--search",
1706 "--project" | "--project-id" => "--project",
1707 "--release" => "--release",
1708 "--environment" | "--env" => "--environment",
1709 "--status" => "--status",
1710 "--limit" => "--limit",
1711 "--min-duration-ms" => "--min-duration-ms",
1712 "--pagination" => "--pagination",
1713 "--cursor-time" => "--cursor-time",
1714 "--cursor-id" => "--cursor-id",
1715 _ => return None,
1716 };
1717 Some(canonical)
1718}
1719
1720fn user_facing_read_flag(flag: &str) -> &str {
1722 match flag {
1723 "--level" => "--severity",
1724 "--service-name" => "--service",
1725 other => other,
1726 }
1727}
1728
1729fn has_invalid_supported_read_value(flag: &str, value: &str) -> bool {
1731 match flag {
1732 "--level" | "--severity" => !is_known_log_level(value),
1733 "--status" => !is_known_issue_status(value),
1734 "--limit" => value.parse::<u32>().map_or(true, |limit| limit == 0),
1735 "--min-duration-ms" => validate_min_duration(value).is_err(),
1736 "--pagination" => value != "cursor",
1737 _ => false,
1738 }
1739}
1740
1741fn is_known_log_level(value: &str) -> bool {
1743 normalize_log_level(value).is_ok()
1744}
1745
1746fn is_ambiguous_log_search_word(value: &str) -> bool {
1748 is_read_filter_word(value) || value.contains('@') || is_trace_id(value)
1749}
1750
1751fn is_known_issue_status(value: &str) -> bool {
1753 normalize_status(value).is_ok()
1754}
1755
1756fn is_read_value_flag(flag: &str) -> bool {
1758 matches!(
1759 flag,
1760 "--name"
1761 | "--service"
1762 | "--service-name"
1763 | "--since"
1764 | "--user"
1765 | "--distinct-id"
1766 | "--trace"
1767 | "--trace-id"
1768 | "--level"
1769 | "--severity"
1770 | "--search"
1771 | "--project"
1772 | "--project-id"
1773 | "--release"
1774 | "--environment"
1775 | "--env"
1776 | "--status"
1777 | "--limit"
1778 | "--min-duration-ms"
1779 | "--pagination"
1780 | "--cursor-time"
1781 | "--cursor-id"
1782 )
1783}
1784
1785fn parse_detail_explain_suffix(
1787 target: ExplainTarget,
1788 args: &[String],
1789) -> Result<Option<Command>, CliError> {
1790 let normalized = move_leading_json_to_tail(args);
1791 if normalized.first().is_none_or(|arg| arg != "explain") {
1792 return Ok(None);
1793 }
1794 Ok(Some(Command::Explain {
1795 target,
1796 json: parse_flags(&normalized[1..], FlagScope::Explain)?.is_json(),
1797 }))
1798}
1799
1800fn parse_detail_read_flags(
1802 args: &[String],
1803 command: &'static str,
1804 next: &'static str,
1805 unsupported_flags: &[&str],
1806) -> Result<crate::flags::Flags, CliError> {
1807 reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
1808 parse_flags(args, FlagScope::Read)
1809}
1810
1811fn parse_pasted_detail_id(id: &str, args: &[String]) -> Result<Command, CliError> {
1813 if is_issue_id(id) && has_issue_status_action(args) {
1814 return parse_issue_first_status_shortcut(id.to_owned(), args);
1815 }
1816 let explain_args = move_leading_json_to_tail(args);
1817 if explain_args.first().is_some_and(|arg| arg == "explain") {
1818 let target = infer_explain_target(id).ok_or_else(|| unknown_command(id))?;
1819 return Ok(Command::Explain {
1820 target,
1821 json: parse_flags(&explain_args[1..], FlagScope::Explain)?.is_json(),
1822 });
1823 }
1824 let (target, flags) = if is_trace_id(id) {
1825 (
1826 ReadTarget::Trace(id.to_owned()),
1827 parse_detail_read_flags(
1828 args,
1829 "read trace",
1830 READ_TRACE_NEXT_STEP,
1831 TRACE_DETAIL_UNSUPPORTED_FLAGS,
1832 )?,
1833 )
1834 } else if is_issue_id(id) {
1835 (
1836 ReadTarget::Issue(id.to_owned()),
1837 parse_detail_read_flags(
1838 args,
1839 "read issue",
1840 READ_ISSUE_NEXT_STEP,
1841 ISSUE_DETAIL_UNSUPPORTED_FLAGS,
1842 )?,
1843 )
1844 } else {
1845 return Err(unknown_command(id));
1846 };
1847 let json = flags.is_json();
1848 let options = flags.into_read_options();
1849 validate_read_filters(&target, &options)?;
1850
1851 Ok(Command::Read {
1852 target,
1853 options: Box::new(options),
1854 json,
1855 })
1856}
1857
1858fn validate_read_filters(target: &ReadTarget, filters: &ReadOptions) -> Result<(), CliError> {
1860 let unsupported = match target {
1861 ReadTarget::Logs => filters
1862 .first_log_unsupported_flag()
1863 .map(|flag| (flag, "read logs", READ_LOGS_NEXT_STEP)),
1864 ReadTarget::Issues => filters
1865 .first_issue_list_unsupported_flag()
1866 .map(|flag| (flag, "read issues", READ_ISSUES_NEXT_STEP)),
1867 ReadTarget::Actions => filters
1868 .first_action_unsupported_flag()
1869 .map(|flag| (flag, "read actions", READ_ACTIONS_NEXT_STEP)),
1870 ReadTarget::Releases => filters
1871 .first_release_unsupported_flag()
1872 .map(|flag| (flag, "read releases", READ_RELEASES_NEXT_STEP)),
1873 ReadTarget::Traces => filters
1874 .first_trace_list_unsupported_flag()
1875 .map(|flag| (flag, "read traces", READ_TRACES_NEXT_STEP)),
1876 ReadTarget::Trace(_) => filters
1877 .first_trace_detail_unsupported_flag()
1878 .map(|flag| (flag, "read trace", READ_TRACE_NEXT_STEP)),
1879 ReadTarget::Issue(_) => filters
1880 .first_issue_detail_unsupported_flag()
1881 .map(|flag| (flag, "read issue", READ_ISSUE_NEXT_STEP)),
1882 };
1883
1884 if let Some((flag, command, next)) = unsupported {
1885 return Err(CliError::UnsupportedFlag {
1886 flag: flag.to_owned(),
1887 command,
1888 next,
1889 });
1890 }
1891 match target {
1892 ReadTarget::Logs => validate_read_cursor(filters, CliError::InvalidLogCursor)?,
1893 ReadTarget::Actions => validate_read_cursor(filters, CliError::InvalidActionCursor)?,
1894 ReadTarget::Issues => validate_read_cursor(filters, CliError::InvalidIssueCursor)?,
1895 ReadTarget::Releases | ReadTarget::Traces | ReadTarget::Trace(_) | ReadTarget::Issue(_) => {
1896 }
1897 }
1898 Ok(())
1899}
1900
1901fn validate_read_cursor(
1903 filters: &ReadOptions,
1904 invalid_cursor: fn(String) -> CliError,
1905) -> Result<(), CliError> {
1906 match (
1907 filters.pagination.as_deref(),
1908 filters.cursor_time.as_ref(),
1909 filters.cursor_id.as_ref(),
1910 ) {
1911 (None | Some("cursor"), None, None) | (Some("cursor"), Some(_), Some(_)) => Ok(()),
1912 (None, _, _) => Err(invalid_cursor(String::from(
1913 "cursor fields require --pagination cursor",
1914 ))),
1915 (Some("cursor"), _, _) => Err(invalid_cursor(String::from(
1916 "--cursor-time and --cursor-id must be used together",
1917 ))),
1918 (Some(_), _, _) => Err(CliError::UnknownPagination),
1919 }
1920}
1921
1922fn parse_explain(args: &[String]) -> Result<Command, CliError> {
1924 let (resource, rest) = take_required_position(args, "resource", EXPLAIN_RESOURCE_NEXT_STEP)?;
1925 let (target, tail) = match resource.as_str() {
1926 "issue" => {
1927 let (id, tail) =
1928 take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1929 (ExplainTarget::Issue(id), tail)
1930 }
1931 "trace" => {
1932 let (id, tail) =
1933 take_required_position(rest.as_slice(), "trace_id", "provide a trace id")?;
1934 (ExplainTarget::Trace(id), tail)
1935 }
1936 other => {
1937 if let Some(target) = infer_explain_target(other) {
1938 (target, rest)
1939 } else {
1940 return Err(unknown_resource(other, EXPLAIN_RESOURCE_NEXT_STEP));
1941 }
1942 }
1943 };
1944 let flags = parse_flags(tail.as_slice(), FlagScope::Explain)?;
1945 Ok(Command::Explain {
1946 target,
1947 json: flags.is_json(),
1948 })
1949}
1950
1951fn parse_set(args: &[String]) -> Result<Command, CliError> {
1953 let (resource, rest) = take_required_position(args, "resource", SET_RESOURCE_NEXT_STEP)?;
1954 if resource != "issue" {
1955 return Err(unknown_resource(resource.as_str(), SET_RESOURCE_NEXT_STEP));
1956 }
1957 let (id, rest) = take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1958 let (status, tail) =
1959 take_required_position(rest.as_slice(), "status", ISSUE_STATUS_ARGUMENT_NEXT_STEP)?;
1960 let status = normalize_status(status.as_str())?;
1961 let flags = parse_flags(tail.as_slice(), FlagScope::Set)?;
1962
1963 Ok(Command::Set {
1964 target: SetTarget::IssueStatus { id, status },
1965 json: flags.is_json(),
1966 })
1967}