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, 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" | "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((subcommand, tail)) = normalized.split_first()
685 && subcommand == "create"
686 {
687 return parse_project_create(tail);
688 }
689 if let Some((subcommand, tail)) = normalized.split_first()
690 && subcommand == "archive"
691 {
692 return parse_project_archive(tail);
693 }
694 if let Some((subcommand, tail)) = normalized.split_first()
695 && subcommand == "setup"
696 && has_position_candidate(tail)
697 {
698 return parse_project_setup_seen(tail);
699 }
700 match normalized.as_slice() {
701 [] => Ok(Command::Projects { json: false }),
702 [flag] if flag == "--json" => Ok(Command::Projects { json: true }),
703 [_, ..] => Err(CliError::InvalidProjectsCommand),
704 }
705}
706
707fn parse_project_archive(args: &[String]) -> Result<Command, CliError> {
709 let normalized = move_leading_json_to_tail(args);
710 let Some((project_id, flags)) = normalized.split_first() else {
711 return Err(CliError::InvalidProjectArchiveCommand);
712 };
713 if !is_uuid(project_id) {
714 return Err(CliError::InvalidProjectArchiveCommand);
715 }
716 let mut yes = false;
717 let mut json = false;
718 for flag in flags {
719 match flag.as_str() {
720 "--yes" if !yes => yes = true,
721 "--json" if !json => json = true,
722 _ => return Err(CliError::InvalidProjectArchiveCommand),
723 }
724 }
725 if !yes {
726 return Err(CliError::InvalidProjectArchiveCommand);
727 }
728 Ok(Command::ProjectArchive {
729 project_id: project_id.to_ascii_lowercase(),
730 json,
731 })
732}
733
734fn parse_project_create(args: &[String]) -> Result<Command, CliError> {
736 let Some((name, tail)) = args.split_first() else {
737 return Err(CliError::InvalidProjectCreateCommand);
738 };
739 if name.starts_with('-') {
740 return Err(CliError::InvalidProjectCreateCommand);
741 }
742 let name = bounded_project_create_value(name, 120, false)
743 .ok_or(CliError::InvalidProjectCreateCommand)?;
744 if name.starts_with('-') {
745 return Err(CliError::InvalidProjectCreateCommand);
746 }
747 let mut runtime = None;
748 let mut environment = None;
749 let mut ingest_key_file = None;
750 let mut abandon_retry = false;
751 let mut json = false;
752 let mut index = 0;
753
754 while let Some(argument) = tail.get(index) {
755 let (flag, inline_value) = argument
756 .split_once('=')
757 .map_or((argument.as_str(), None), |(flag, value)| {
758 (flag, Some(value))
759 });
760 match flag {
761 "--runtime" if runtime.is_none() => {
762 let value = project_create_flag_value(tail, &mut index, inline_value)?;
763 runtime = optional_project_create_value(value, 64)?;
764 }
765 "--environment" if environment.is_none() => {
766 let value = project_create_flag_value(tail, &mut index, inline_value)?;
767 environment = optional_project_create_value(value, 64)?;
768 }
769 "--ingest-key-file" if ingest_key_file.is_none() => {
770 let value = project_create_flag_value(tail, &mut index, inline_value)?;
771 let trimmed = value.trim();
772 if trimmed.is_empty()
773 || trimmed.len() > 4096
774 || trimmed.chars().any(char::is_control)
775 {
776 return Err(CliError::InvalidProjectCreateCommand);
777 }
778 ingest_key_file = Some(trimmed.to_owned());
779 }
780 "--abandon-retry" if inline_value.is_none() && !abandon_retry => {
781 abandon_retry = true;
782 }
783 "--json" if inline_value.is_none() && !json => json = true,
784 _ => return Err(CliError::InvalidProjectCreateCommand),
785 }
786 index += 1;
787 }
788
789 let ingest_key_file = ingest_key_file.ok_or(CliError::InvalidProjectCreateCommand)?;
790 Ok(Command::ProjectCreate {
791 options: ProjectCreateOptions {
792 name,
793 runtime,
794 environment,
795 ingest_key_file,
796 abandon_retry,
797 },
798 json,
799 })
800}
801
802fn project_create_flag_value<'a>(
804 args: &'a [String],
805 index: &mut usize,
806 inline: Option<&'a str>,
807) -> Result<&'a str, CliError> {
808 if let Some(value) = inline {
809 return Ok(value);
810 }
811 *index += 1;
812 args.get(*index)
813 .map(String::as_str)
814 .filter(|value| !value.starts_with('-'))
815 .ok_or(CliError::InvalidProjectCreateCommand)
816}
817
818fn bounded_project_create_value(value: &str, limit: usize, allow_blank: bool) -> Option<String> {
820 let value = value.trim();
821 let length = value.chars().count();
822 if value.chars().any(char::is_control) || length > limit || (!allow_blank && length == 0) {
823 return None;
824 }
825 (!value.is_empty()).then(|| value.to_owned())
826}
827
828fn optional_project_create_value(value: &str, limit: usize) -> Result<Option<String>, CliError> {
830 let trimmed = value.trim();
831 if trimmed.is_empty() {
832 return Ok(None);
833 }
834 bounded_project_create_value(trimmed, limit, false)
835 .map(Some)
836 .ok_or(CliError::InvalidProjectCreateCommand)
837}
838
839fn parse_project_setup_seen(args: &[String]) -> Result<Command, CliError> {
841 let (project_id, tail) =
842 take_required_position(args, "project_id", PROJECT_SETUP_SEEN_NEXT_STEP)?;
843 let (options, json) = parse_project_setup_seen_flags(tail.as_slice())?;
844 Ok(Command::ProjectSetupSeen {
845 project_id,
846 options,
847 json,
848 })
849}
850
851fn parse_project_setup_seen_flags(
853 args: &[String],
854) -> Result<(ProjectSetupSeenOptions, bool), CliError> {
855 let mut options = ProjectSetupSeenOptions::default();
856 let mut json = false;
857 let mut seen = Vec::new();
858 let mut index = 0;
859
860 while let Some(arg) = args.get(index) {
861 let (flag, inline_value) = split_project_setup_seen_inline_value(arg.as_str());
862 match flag {
863 "--json" if inline_value.is_none() => {
864 mark_project_setup_seen_flag(&mut seen, "--json")?;
865 json = true;
866 }
867 "--runtime" => {
868 mark_project_setup_seen_flag(&mut seen, "--runtime")?;
869 options.runtime = Some(project_setup_seen_flag_value(
870 args,
871 &mut index,
872 "--runtime",
873 inline_value,
874 )?);
875 }
876 "--source" => {
877 mark_project_setup_seen_flag(&mut seen, "--source")?;
878 options.source = Some(validate_project_setup_seen_source(
879 project_setup_seen_flag_value(args, &mut index, "--source", inline_value)?
880 .as_str(),
881 )?);
882 }
883 "--environment" | "--env" => {
884 mark_project_setup_seen_flag(&mut seen, "--environment")?;
885 let visible_flag = if flag == "--env" {
886 "--env"
887 } else {
888 "--environment"
889 };
890 options.environment = Some(project_setup_seen_flag_value(
891 args,
892 &mut index,
893 visible_flag,
894 inline_value,
895 )?);
896 }
897 flag if flag.starts_with('-') => {
898 return Err(unknown_flag(flag, PROJECT_SETUP_SEEN_NEXT_STEP));
899 }
900 argument => {
901 return Err(CliError::UnexpectedArgument {
902 argument: argument.to_owned(),
903 command: "projects setup",
904 next: PROJECT_SETUP_SEEN_NEXT_STEP,
905 });
906 }
907 }
908 index += 1;
909 }
910
911 Ok((options, json))
912}
913
914fn split_project_setup_seen_inline_value(flag: &str) -> (&str, Option<&str>) {
916 flag.split_once('=')
917 .map_or((flag, None), |(name, value)| (name, Some(value)))
918}
919
920fn mark_project_setup_seen_flag(
922 seen: &mut Vec<&'static str>,
923 flag: &'static str,
924) -> Result<(), CliError> {
925 if seen.contains(&flag) {
926 return Err(CliError::DuplicateFlag {
927 flag,
928 next: project_setup_seen_duplicate_next(flag),
929 });
930 }
931 seen.push(flag);
932 Ok(())
933}
934
935fn project_setup_seen_duplicate_next(flag: &'static str) -> &'static str {
937 match flag {
938 "--json" => "use --json once",
939 "--runtime" => "use --runtime once",
940 "--source" => "use --source once",
941 "--environment" => "use --environment once",
942 _ => "use the flag once",
943 }
944}
945
946fn project_setup_seen_flag_value(
948 args: &[String],
949 index: &mut usize,
950 flag: &'static str,
951 inline_value: Option<&str>,
952) -> Result<String, CliError> {
953 if let Some(value) = inline_value {
954 if value.is_empty() {
955 return Err(missing_project_setup_seen_flag_value(flag));
956 }
957 return Ok(value.to_owned());
958 }
959 *index += 1;
960 let Some(value) = args.get(*index) else {
961 return Err(missing_project_setup_seen_flag_value(flag));
962 };
963 if value.starts_with('-') {
964 return Err(missing_project_setup_seen_flag_value(flag));
965 }
966 Ok(value.clone())
967}
968
969fn missing_project_setup_seen_flag_value(flag: &'static str) -> CliError {
971 CliError::MissingFlagValue {
972 flag,
973 next: project_setup_seen_missing_value_next(flag),
974 }
975}
976
977fn project_setup_seen_missing_value_next(flag: &'static str) -> &'static str {
979 match flag {
980 "--runtime" => "provide a value after --runtime",
981 "--source" => PROJECT_SETUP_SOURCE_NEXT_STEP,
982 "--environment" => "provide a value after --environment",
983 "--env" => "provide a value after --env",
984 _ => "provide a value after the flag",
985 }
986}
987
988fn validate_project_setup_seen_source(source: &str) -> Result<String, CliError> {
990 match source {
991 "api" | "cli" | "sdk" => Ok(source.to_owned()),
992 other => Err(CliError::InvalidSetupSource(other.to_owned())),
993 }
994}
995
996fn parse_status(args: &[String]) -> Result<Command, CliError> {
998 let flags = parse_flags(args, FlagScope::Status)?;
999 Ok(Command::Status {
1000 json: flags.is_json(),
1001 })
1002}
1003
1004fn parse_whoami(args: &[String]) -> Result<Command, CliError> {
1006 let flags = parse_flags(args, FlagScope::WhoAmI)?;
1007 Ok(Command::WhoAmI {
1008 json: flags.is_json(),
1009 })
1010}
1011
1012fn parse_usage(args: &[String]) -> Result<Command, CliError> {
1014 match args {
1015 [] => Ok(Command::Usage { json: false }),
1016 [flag] if flag == "--json" => Ok(Command::Usage { json: true }),
1017 _ => Err(CliError::InvalidUsageCommand),
1018 }
1019}
1020
1021fn parse_doctor(args: &[String]) -> Result<Command, CliError> {
1023 if args.iter().all(|arg| arg == "--json") {
1024 return parse_status(args);
1025 }
1026
1027 let mut project_id = None;
1028 let mut json = false;
1029 let mut index = 0;
1030 while let Some(argument) = args.get(index) {
1031 if let Some(value) = argument
1032 .strip_prefix("--project=")
1033 .or_else(|| argument.strip_prefix("--project-id="))
1034 {
1035 if project_id.is_some() || !is_uuid(value) {
1036 return Err(CliError::InvalidDoctorCommand);
1037 }
1038 project_id = Some(value.to_owned());
1039 index += 1;
1040 continue;
1041 }
1042 match argument.as_str() {
1043 "--json" if !json => json = true,
1044 "--project" | "--project-id" if project_id.is_none() => {
1045 index += 1;
1046 let Some(value) = args.get(index) else {
1047 return Err(CliError::InvalidDoctorCommand);
1048 };
1049 if !is_uuid(value) {
1050 return Err(CliError::InvalidDoctorCommand);
1051 }
1052 project_id = Some(value.clone());
1053 }
1054 _ => return Err(CliError::InvalidDoctorCommand),
1055 }
1056 index += 1;
1057 }
1058
1059 project_id.map_or(Err(CliError::InvalidDoctorCommand), |project_id| {
1060 Ok(Command::Doctor { project_id, json })
1061 })
1062}
1063
1064fn parse_version(args: &[String]) -> Result<Command, CliError> {
1066 let flags = parse_flags(args, FlagScope::Version)?;
1067 Ok(Command::Version {
1068 json: flags.is_json(),
1069 })
1070}
1071
1072fn take_required_arg<'a>(
1074 args: &'a [String],
1075 argument: &'static str,
1076 next: &'static str,
1077) -> Result<(&'a str, &'a [String]), CliError> {
1078 let Some((value, rest)) = args.split_first() else {
1079 return Err(CliError::MissingArgument { argument, next });
1080 };
1081 if value.starts_with('-') {
1082 return Err(CliError::MissingArgument { argument, next });
1083 }
1084 Ok((value.as_str(), rest))
1085}
1086
1087fn move_leading_json_to_tail(args: &[String]) -> Vec<String> {
1089 if args.first().is_some_and(|arg| arg == "--json") {
1090 let mut normalized = Vec::with_capacity(args.len());
1091 normalized.extend(args[1..].iter().cloned());
1092 normalized.push(String::from("--json"));
1093 normalized
1094 } else {
1095 args.to_vec()
1096 }
1097}
1098
1099fn has_position_candidate(args: &[String]) -> bool {
1101 move_leading_json_to_tail(args)
1102 .first()
1103 .is_some_and(|arg| !arg.starts_with('-'))
1104}
1105
1106fn has_trace_id_candidate(args: &[String]) -> bool {
1108 move_leading_json_to_tail(args)
1109 .first()
1110 .is_some_and(|arg| is_trace_id(arg))
1111}
1112
1113fn take_required_position(
1115 args: &[String],
1116 argument: &'static str,
1117 next: &'static str,
1118) -> Result<(String, Vec<String>), CliError> {
1119 let normalized = move_leading_json_to_tail(args);
1120 let (value, rest) = take_required_arg(normalized.as_slice(), argument, next)?;
1121 Ok((value.to_owned(), rest.to_vec()))
1122}
1123
1124fn parse_read(args: &[String]) -> Result<Command, CliError> {
1126 let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
1127 let resource = normalize_read_resource(resource.as_str());
1128 if is_recency_read_verb(resource) {
1129 return parse_read_verb(resource, rest.as_slice());
1130 }
1131 if is_known_issue_status(resource) && has_status_first_issue_resource_candidate(rest.as_slice())
1132 {
1133 return parse_status_first_issue_read(resource, rest.as_slice());
1134 }
1135 parse_read_resource(resource, rest.as_slice())
1136}
1137
1138fn normalize_read_resource(resource: &str) -> &str {
1140 match resource {
1141 "log" => "logs",
1142 "release" => "releases",
1143 _ => resource,
1144 }
1145}
1146
1147fn parse_read_verb(verb: &str, args: &[String]) -> Result<Command, CliError> {
1149 let rewritten_args = recency_count_shortcut_args(verb, args);
1150 let args = rewritten_args.as_deref().unwrap_or(args);
1151 let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
1152 if is_known_issue_status(resource.as_str())
1153 && has_status_first_issue_resource_candidate(rest.as_slice())
1154 {
1155 return parse_status_first_issue_read(resource.as_str(), rest.as_slice());
1156 }
1157 let resource = normalize_read_verb_resource(verb, resource.as_str());
1158 parse_read_resource(resource, rest.as_slice())
1159}
1160
1161fn recency_count_shortcut_args(verb: &str, args: &[String]) -> Option<Vec<String>> {
1163 if !is_recency_read_verb(verb) {
1164 return None;
1165 }
1166 let normalized = move_leading_json_to_tail(args);
1167 let (count, tail) = normalized.split_first().filter(|(count, tail)| {
1168 !tail.is_empty() && count.chars().all(|char| char.is_ascii_digit())
1169 })?;
1170 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1171 rewritten.push(tail[0].clone());
1172 let rest = &tail[1..];
1173 if let Some(separator_index) = rest.iter().position(|arg| arg == "--") {
1174 rewritten.extend(rest[..separator_index].iter().cloned());
1175 rewritten.push(String::from("--limit"));
1176 rewritten.push(count.clone());
1177 rewritten.extend(rest[separator_index..].iter().cloned());
1178 return Some(rewritten);
1179 }
1180 rewritten.extend(rest.iter().cloned());
1181 rewritten.push(String::from("--limit"));
1182 rewritten.push(count.clone());
1183 Some(rewritten)
1184}
1185
1186fn is_read_verb(value: &str) -> bool {
1188 matches!(value, "show" | "list" | "get") || is_recency_read_verb(value)
1189}
1190
1191fn is_recency_read_verb(value: &str) -> bool {
1193 matches!(value, "latest" | "recent" | "last" | "newest")
1194}
1195
1196fn normalize_read_verb_resource<'a>(verb: &str, resource: &'a str) -> &'a str {
1198 match (verb, resource) {
1199 ("list" | "show" | "get", "log") => "logs",
1200 (alias, "log") if is_recency_read_verb(alias) => "logs",
1201 (alias, "issue") if is_recency_read_verb(alias) => "issues",
1202 ("list", "issue") => "issues",
1203 ("list" | "show" | "get", "release") => "releases",
1204 (alias, "release") if is_recency_read_verb(alias) => "releases",
1205 _ => resource,
1206 }
1207}
1208
1209fn is_log_search_shortcut(command: &str) -> bool {
1211 matches!(command, "search" | "find" | "grep")
1212}
1213
1214fn is_log_search_separator_literal(command: &str, args: &[String]) -> bool {
1216 literal_log_search_separator_index(command, args).is_some()
1217}
1218
1219fn log_search_shortcut_label(command: &str) -> &'static str {
1221 match command {
1222 "find" => "find",
1223 "grep" => "grep",
1224 _ => "search",
1225 }
1226}
1227
1228fn parse_search_shortcut(label: &'static str, args: &[String]) -> Result<Command, CliError> {
1230 let (query, tail) = take_search_query(args, label)?;
1231 let mut rest = Vec::with_capacity(tail.len() + 2);
1232 if query.starts_with('-') {
1233 rest.push(format!("--search={query}"));
1234 } else {
1235 rest.push(String::from("--search"));
1236 rest.push(query);
1237 }
1238 rest.extend(tail);
1239 parse_read_resource("logs", rest.as_slice())
1240}
1241
1242fn take_search_query(
1244 args: &[String],
1245 argument: &'static str,
1246) -> Result<(String, Vec<String>), CliError> {
1247 let normalized = move_leading_json_to_tail(args);
1248 if normalized.first().is_some_and(|arg| arg == "--") {
1249 return take_separator_search_query(normalized.as_slice(), argument);
1250 }
1251 let query_word_count = normalized
1252 .iter()
1253 .take_while(|arg| !arg.starts_with('-'))
1254 .count();
1255 if query_word_count == 0 {
1256 return Err(CliError::MissingArgument {
1257 argument,
1258 next: SEARCH_NEXT_STEP,
1259 });
1260 }
1261 let query = normalized[..query_word_count].join(" ");
1262 let tail = normalized[query_word_count..].to_vec();
1263 Ok((query, tail))
1264}
1265
1266fn take_separator_search_query(
1268 args: &[String],
1269 argument: &'static str,
1270) -> Result<(String, Vec<String>), CliError> {
1271 let words = &args[1..];
1272 if words.is_empty() {
1273 return Err(CliError::MissingArgument {
1274 argument,
1275 next: SEARCH_NEXT_STEP,
1276 });
1277 }
1278 let has_trailing_json_mode = words.len() > 1 && words.last().is_some_and(|arg| arg == "--json");
1279 let query_end = if has_trailing_json_mode {
1280 words.len() - 1
1281 } else {
1282 words.len()
1283 };
1284 let query = words[..query_end].join(" ");
1285 let tail = if has_trailing_json_mode {
1286 vec![String::from("--json")]
1287 } else {
1288 Vec::new()
1289 };
1290 Ok((query, tail))
1291}
1292
1293fn parse_read_resource(resource: &str, rest: &[String]) -> Result<Command, CliError> {
1295 let (target, flags) = match resource {
1296 "logs" => parse_log_list_read(rest)?,
1297 alias if is_issue_collection_alias(alias) && has_issue_id_candidate(rest) => {
1298 return parse_issue_detail_or_status(rest);
1299 }
1300 alias if is_issue_collection_alias(alias) => parse_issue_list_read(rest)?,
1301 alias if is_action_collection_alias(alias) => parse_action_list_read(rest)?,
1302 "releases" => parse_list_read(
1303 ReadTarget::Releases,
1304 rest,
1305 "read releases",
1306 READ_RELEASES_NEXT_STEP,
1307 &[
1308 "--name",
1309 "--user",
1310 "--distinct-id",
1311 "--trace",
1312 "--trace-id",
1313 "--level",
1314 "--severity",
1315 "--search",
1316 "--status",
1317 "--min-duration-ms",
1318 ],
1319 )?,
1320 "traces" | "spans" if has_trace_id_candidate(rest) => {
1321 return parse_trace_detail_or_explain(rest);
1322 }
1323 "traces" | "spans" => parse_trace_list_read(rest)?,
1324 "trace" => return parse_trace_detail_or_explain(rest),
1325 "span" if has_position_candidate(rest) => {
1326 return parse_trace_detail_or_explain(rest);
1327 }
1328 "issue" if has_issue_status_candidate(rest) => parse_issue_list_read(rest)?,
1329 "issue" => return parse_issue_detail_or_status(rest),
1330 other => return Err(unknown_read_resource(other)),
1331 };
1332 let json = flags.is_json();
1333 let options = flags.into_read_options();
1334 validate_read_filters(&target, &options)?;
1335
1336 Ok(Command::Read {
1337 target,
1338 options: Box::new(options),
1339 json,
1340 })
1341}
1342
1343fn is_issue_collection_alias(value: &str) -> bool {
1345 matches!(
1346 value,
1347 "issues" | "errors" | "error" | "exceptions" | "exception"
1348 )
1349}
1350
1351fn is_status_first_issue_collection_alias(value: &str) -> bool {
1353 value == "issue" || is_issue_collection_alias(value)
1354}
1355
1356fn has_status_first_issue_resource_candidate(args: &[String]) -> bool {
1358 move_leading_json_to_tail(args)
1359 .first()
1360 .is_some_and(|arg| is_status_first_issue_collection_alias(arg))
1361}
1362
1363fn has_issue_status_candidate(args: &[String]) -> bool {
1365 move_leading_json_to_tail(args)
1366 .first()
1367 .is_some_and(|arg| is_known_issue_status(arg))
1368}
1369
1370fn is_action_collection_alias(value: &str) -> bool {
1372 matches!(value, "actions" | "events" | "event" | "action")
1373}
1374
1375fn parse_log_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1377 let args = log_shortcut_args(rest);
1378 parse_list_read(
1379 ReadTarget::Logs,
1380 args.as_slice(),
1381 "read logs",
1382 READ_LOGS_NEXT_STEP,
1383 &[
1384 "--name",
1385 "--user",
1386 "--distinct-id",
1387 "--status",
1388 "--min-duration-ms",
1389 ],
1390 )
1391}
1392
1393fn parse_issue_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1395 let args = issue_status_shortcut_args(rest);
1396 parse_list_read(
1397 ReadTarget::Issues,
1398 args.as_slice(),
1399 "read issues",
1400 READ_ISSUES_NEXT_STEP,
1401 &[
1402 "--name",
1403 "--user",
1404 "--distinct-id",
1405 "--trace",
1406 "--trace-id",
1407 "--level",
1408 "--severity",
1409 "--search",
1410 "--min-duration-ms",
1411 ],
1412 )
1413}
1414
1415fn parse_status_first_issue_read(status: &str, args: &[String]) -> Result<Command, CliError> {
1417 let canonical_status = normalize_status(status)?;
1418 let (resource, rest) = take_required_position(args, "resource", READ_ISSUES_NEXT_STEP)?;
1419 let resource = resource.as_str();
1420 if !is_status_first_issue_collection_alias(resource) {
1421 return Err(unknown_read_resource(resource));
1422 }
1423 let resource = if resource == "issue" {
1424 "issues"
1425 } else {
1426 resource
1427 };
1428 let mut rewritten = Vec::with_capacity(rest.len() + 2);
1429 rewritten.push(String::from("--status"));
1430 rewritten.push(canonical_status);
1431 rewritten.extend(rest);
1432 parse_read_resource(resource, rewritten.as_slice())
1433}
1434
1435fn issue_status_shortcut_args(args: &[String]) -> Vec<String> {
1437 let normalized = move_leading_json_to_tail(args);
1438 let Some((status, tail)) = normalized
1439 .split_first()
1440 .and_then(|(status, tail)| normalize_status(status).ok().map(|value| (value, tail)))
1441 else {
1442 return args.to_vec();
1443 };
1444 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1445 rewritten.push(String::from("--status"));
1446 rewritten.push(status);
1447 rewritten.extend(tail.iter().cloned());
1448 rewritten
1449}
1450
1451fn parse_action_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1453 let args = action_name_shortcut_args(rest);
1454 parse_list_read(
1455 ReadTarget::Actions,
1456 args.as_slice(),
1457 "read actions",
1458 READ_ACTIONS_NEXT_STEP,
1459 ACTION_LIST_UNSUPPORTED_FLAGS,
1460 )
1461}
1462
1463fn action_name_shortcut_args(args: &[String]) -> Vec<String> {
1465 let normalized = move_leading_json_to_tail(args);
1466 let Some((name, tail)) = normalized
1467 .split_first()
1468 .filter(|(name, _)| !name.starts_with('-') && !is_read_filter_word(name))
1469 else {
1470 return args.to_vec();
1471 };
1472 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1473 rewritten.push(String::from("--name"));
1474 rewritten.push(name.clone());
1475 rewritten.extend(tail.iter().cloned());
1476 rewritten
1477}
1478
1479fn has_issue_id_candidate(args: &[String]) -> bool {
1481 move_leading_json_to_tail(args)
1482 .first()
1483 .is_some_and(|arg| is_issue_id(arg))
1484}
1485
1486fn parse_issue_detail_or_status(args: &[String]) -> Result<Command, CliError> {
1488 let (id, tail) = take_required_position(args, "issue_id", "provide an issue id")?;
1489 if has_issue_status_action(tail.as_slice()) {
1490 return parse_issue_first_status_shortcut(id, tail.as_slice());
1491 }
1492 if let Some(command) =
1493 parse_detail_explain_suffix(ExplainTarget::Issue(id.clone()), tail.as_slice())?
1494 {
1495 return Ok(command);
1496 }
1497 let target = ReadTarget::Issue(id);
1498 let flags = parse_detail_read_flags(
1499 tail.as_slice(),
1500 "read issue",
1501 READ_ISSUE_NEXT_STEP,
1502 ISSUE_DETAIL_UNSUPPORTED_FLAGS,
1503 )?;
1504 let json = flags.is_json();
1505 let options = flags.into_read_options();
1506 validate_read_filters(&target, &options)?;
1507
1508 Ok(Command::Read {
1509 target,
1510 options: Box::new(options),
1511 json,
1512 })
1513}
1514
1515fn parse_list_read(
1517 target: ReadTarget,
1518 args: &[String],
1519 command: &'static str,
1520 next: &'static str,
1521 unsupported_flags: &[&str],
1522) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1523 reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
1524 Ok((target, parse_flags(args, FlagScope::Read)?))
1525}
1526
1527fn reject_unsupported_read_flags(
1529 args: &[String],
1530 command: &'static str,
1531 next: &'static str,
1532 unsupported_flags: &[&str],
1533) -> Result<(), CliError> {
1534 let mut index = 0;
1535 let mut seen = Vec::new();
1536 while let Some(arg) = args.get(index) {
1537 let (flag, inline_value) = arg
1538 .split_once('=')
1539 .map_or((arg.as_str(), None), |(name, value)| (name, Some(value)));
1540 if !is_read_value_flag(flag) {
1541 if flag == "--json" && inline_value.is_none() {
1542 if seen.contains(&"--json") {
1543 return Ok(());
1544 }
1545 seen.push("--json");
1546 index += 1;
1547 continue;
1548 }
1549 if inline_value.is_some() && is_simple_flag(flag) {
1550 return Err(CliError::UnsupportedFlag {
1551 flag: arg.to_owned(),
1552 command,
1553 next,
1554 });
1555 }
1556 if arg.starts_with('-') {
1557 return Err(unknown_flag(arg, next));
1558 }
1559 return Ok(());
1560 }
1561 if unsupported_flags.contains(&flag) {
1562 return Err(CliError::UnsupportedFlag {
1563 flag: user_facing_read_flag(flag).to_owned(),
1564 command,
1565 next,
1566 });
1567 }
1568 if let Some(canonical) = read_value_canonical_flag(flag) {
1569 if seen.contains(&canonical) {
1570 return Ok(());
1571 }
1572 seen.push(canonical);
1573 }
1574 if inline_value.is_some_and(str::is_empty) {
1575 return Ok(());
1576 }
1577 if inline_value.is_some_and(|value| has_invalid_supported_read_value(flag, value)) {
1578 return Ok(());
1579 }
1580 if inline_value.is_none() {
1581 let Some(value) = args.get(index + 1) else {
1582 return Ok(());
1583 };
1584 if value.starts_with('-') {
1585 return Ok(());
1586 }
1587 if has_invalid_supported_read_value(flag, value) {
1588 return Ok(());
1589 }
1590 index += 1;
1591 }
1592 index += 1;
1593 }
1594 Ok(())
1595}
1596
1597fn read_value_canonical_flag(flag: &str) -> Option<&'static str> {
1599 let canonical = match flag {
1600 "--name" => "--name",
1601 "--service" | "--service-name" => "--service",
1602 "--since" => "--since",
1603 "--user" | "--distinct-id" => "--user",
1604 "--trace" | "--trace-id" => "--trace",
1605 "--level" | "--severity" => "--severity",
1606 "--search" => "--search",
1607 "--project" | "--project-id" => "--project",
1608 "--release" => "--release",
1609 "--environment" | "--env" => "--environment",
1610 "--status" => "--status",
1611 "--limit" => "--limit",
1612 "--min-duration-ms" => "--min-duration-ms",
1613 "--pagination" => "--pagination",
1614 "--cursor-time" => "--cursor-time",
1615 "--cursor-id" => "--cursor-id",
1616 _ => return None,
1617 };
1618 Some(canonical)
1619}
1620
1621fn user_facing_read_flag(flag: &str) -> &str {
1623 match flag {
1624 "--level" => "--severity",
1625 "--service-name" => "--service",
1626 other => other,
1627 }
1628}
1629
1630fn has_invalid_supported_read_value(flag: &str, value: &str) -> bool {
1632 match flag {
1633 "--level" | "--severity" => !is_known_log_level(value),
1634 "--status" => !is_known_issue_status(value),
1635 "--limit" => value.parse::<u32>().map_or(true, |limit| limit == 0),
1636 "--min-duration-ms" => validate_min_duration(value).is_err(),
1637 "--pagination" => value != "cursor",
1638 _ => false,
1639 }
1640}
1641
1642fn is_known_log_level(value: &str) -> bool {
1644 normalize_log_level(value).is_ok()
1645}
1646
1647fn is_ambiguous_log_search_word(value: &str) -> bool {
1649 is_read_filter_word(value) || value.contains('@') || is_trace_id(value)
1650}
1651
1652fn is_known_issue_status(value: &str) -> bool {
1654 normalize_status(value).is_ok()
1655}
1656
1657fn is_read_value_flag(flag: &str) -> bool {
1659 matches!(
1660 flag,
1661 "--name"
1662 | "--service"
1663 | "--service-name"
1664 | "--since"
1665 | "--user"
1666 | "--distinct-id"
1667 | "--trace"
1668 | "--trace-id"
1669 | "--level"
1670 | "--severity"
1671 | "--search"
1672 | "--project"
1673 | "--project-id"
1674 | "--release"
1675 | "--environment"
1676 | "--env"
1677 | "--status"
1678 | "--limit"
1679 | "--min-duration-ms"
1680 | "--pagination"
1681 | "--cursor-time"
1682 | "--cursor-id"
1683 )
1684}
1685
1686fn parse_detail_explain_suffix(
1688 target: ExplainTarget,
1689 args: &[String],
1690) -> Result<Option<Command>, CliError> {
1691 let normalized = move_leading_json_to_tail(args);
1692 if normalized.first().is_none_or(|arg| arg != "explain") {
1693 return Ok(None);
1694 }
1695 Ok(Some(Command::Explain {
1696 target,
1697 json: parse_flags(&normalized[1..], FlagScope::Explain)?.is_json(),
1698 }))
1699}
1700
1701fn parse_detail_read_flags(
1703 args: &[String],
1704 command: &'static str,
1705 next: &'static str,
1706 unsupported_flags: &[&str],
1707) -> Result<crate::flags::Flags, CliError> {
1708 reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
1709 parse_flags(args, FlagScope::Read)
1710}
1711
1712fn parse_pasted_detail_id(id: &str, args: &[String]) -> Result<Command, CliError> {
1714 if is_issue_id(id) && has_issue_status_action(args) {
1715 return parse_issue_first_status_shortcut(id.to_owned(), args);
1716 }
1717 let explain_args = move_leading_json_to_tail(args);
1718 if explain_args.first().is_some_and(|arg| arg == "explain") {
1719 let target = infer_explain_target(id).ok_or_else(|| unknown_command(id))?;
1720 return Ok(Command::Explain {
1721 target,
1722 json: parse_flags(&explain_args[1..], FlagScope::Explain)?.is_json(),
1723 });
1724 }
1725 let (target, flags) = if is_trace_id(id) {
1726 (
1727 ReadTarget::Trace(id.to_owned()),
1728 parse_detail_read_flags(
1729 args,
1730 "read trace",
1731 READ_TRACE_NEXT_STEP,
1732 TRACE_DETAIL_UNSUPPORTED_FLAGS,
1733 )?,
1734 )
1735 } else if is_issue_id(id) {
1736 (
1737 ReadTarget::Issue(id.to_owned()),
1738 parse_detail_read_flags(
1739 args,
1740 "read issue",
1741 READ_ISSUE_NEXT_STEP,
1742 ISSUE_DETAIL_UNSUPPORTED_FLAGS,
1743 )?,
1744 )
1745 } else {
1746 return Err(unknown_command(id));
1747 };
1748 let json = flags.is_json();
1749 let options = flags.into_read_options();
1750 validate_read_filters(&target, &options)?;
1751
1752 Ok(Command::Read {
1753 target,
1754 options: Box::new(options),
1755 json,
1756 })
1757}
1758
1759fn validate_read_filters(target: &ReadTarget, filters: &ReadOptions) -> Result<(), CliError> {
1761 let unsupported = match target {
1762 ReadTarget::Logs => filters
1763 .first_log_unsupported_flag()
1764 .map(|flag| (flag, "read logs", READ_LOGS_NEXT_STEP)),
1765 ReadTarget::Issues => filters
1766 .first_issue_list_unsupported_flag()
1767 .map(|flag| (flag, "read issues", READ_ISSUES_NEXT_STEP)),
1768 ReadTarget::Actions => filters
1769 .first_action_unsupported_flag()
1770 .map(|flag| (flag, "read actions", READ_ACTIONS_NEXT_STEP)),
1771 ReadTarget::Releases => filters
1772 .first_release_unsupported_flag()
1773 .map(|flag| (flag, "read releases", READ_RELEASES_NEXT_STEP)),
1774 ReadTarget::Traces => filters
1775 .first_trace_list_unsupported_flag()
1776 .map(|flag| (flag, "read traces", READ_TRACES_NEXT_STEP)),
1777 ReadTarget::Trace(_) => filters
1778 .first_trace_detail_unsupported_flag()
1779 .map(|flag| (flag, "read trace", READ_TRACE_NEXT_STEP)),
1780 ReadTarget::Issue(_) => filters
1781 .first_issue_detail_unsupported_flag()
1782 .map(|flag| (flag, "read issue", READ_ISSUE_NEXT_STEP)),
1783 };
1784
1785 if let Some((flag, command, next)) = unsupported {
1786 return Err(CliError::UnsupportedFlag {
1787 flag: flag.to_owned(),
1788 command,
1789 next,
1790 });
1791 }
1792 match target {
1793 ReadTarget::Logs => validate_read_cursor(filters, CliError::InvalidLogCursor)?,
1794 ReadTarget::Actions => validate_read_cursor(filters, CliError::InvalidActionCursor)?,
1795 ReadTarget::Issues => validate_read_cursor(filters, CliError::InvalidIssueCursor)?,
1796 ReadTarget::Releases | ReadTarget::Traces | ReadTarget::Trace(_) | ReadTarget::Issue(_) => {
1797 }
1798 }
1799 Ok(())
1800}
1801
1802fn validate_read_cursor(
1804 filters: &ReadOptions,
1805 invalid_cursor: fn(String) -> CliError,
1806) -> Result<(), CliError> {
1807 match (
1808 filters.pagination.as_deref(),
1809 filters.cursor_time.as_ref(),
1810 filters.cursor_id.as_ref(),
1811 ) {
1812 (None | Some("cursor"), None, None) | (Some("cursor"), Some(_), Some(_)) => Ok(()),
1813 (None, _, _) => Err(invalid_cursor(String::from(
1814 "cursor fields require --pagination cursor",
1815 ))),
1816 (Some("cursor"), _, _) => Err(invalid_cursor(String::from(
1817 "--cursor-time and --cursor-id must be used together",
1818 ))),
1819 (Some(_), _, _) => Err(CliError::UnknownPagination),
1820 }
1821}
1822
1823fn parse_explain(args: &[String]) -> Result<Command, CliError> {
1825 let (resource, rest) = take_required_position(args, "resource", EXPLAIN_RESOURCE_NEXT_STEP)?;
1826 let (target, tail) = match resource.as_str() {
1827 "issue" => {
1828 let (id, tail) =
1829 take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1830 (ExplainTarget::Issue(id), tail)
1831 }
1832 "trace" => {
1833 let (id, tail) =
1834 take_required_position(rest.as_slice(), "trace_id", "provide a trace id")?;
1835 (ExplainTarget::Trace(id), tail)
1836 }
1837 other => {
1838 if let Some(target) = infer_explain_target(other) {
1839 (target, rest)
1840 } else {
1841 return Err(unknown_resource(other, EXPLAIN_RESOURCE_NEXT_STEP));
1842 }
1843 }
1844 };
1845 let flags = parse_flags(tail.as_slice(), FlagScope::Explain)?;
1846 Ok(Command::Explain {
1847 target,
1848 json: flags.is_json(),
1849 })
1850}
1851
1852fn parse_set(args: &[String]) -> Result<Command, CliError> {
1854 let (resource, rest) = take_required_position(args, "resource", SET_RESOURCE_NEXT_STEP)?;
1855 if resource != "issue" {
1856 return Err(unknown_resource(resource.as_str(), SET_RESOURCE_NEXT_STEP));
1857 }
1858 let (id, rest) = take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1859 let (status, tail) =
1860 take_required_position(rest.as_slice(), "status", ISSUE_STATUS_ARGUMENT_NEXT_STEP)?;
1861 let status = normalize_status(status.as_str())?;
1862 let flags = parse_flags(tail.as_slice(), FlagScope::Set)?;
1863
1864 Ok(Command::Set {
1865 target: SetTarget::IssueStatus { id, status },
1866 json: flags.is_json(),
1867 })
1868}