1mod help_topics;
4mod issue_shortcuts;
5mod log_shortcuts;
6mod support;
7mod trace_reads;
8mod watch;
9
10use help_topics::{
11 command_shaped_help_topic, contains_help_flag, ensure_no_help_positionals, help_command,
12 help_topic, is_direct_filter_help_alias, is_help_flag, parse_help, parse_help_alias,
13 parse_literal_help, positional_args, validate_help_flags,
14};
15use issue_shortcuts::{
16 has_issue_status_action, is_issue_status_action_alias, parse_bare_issue_status_shortcut,
17 parse_issue_first_status_shortcut, parse_issue_status_shortcut,
18 parse_status_first_issue_id_shortcut,
19};
20use log_shortcuts::{literal_log_search_separator_index, log_shortcut_args};
21use support::parse_support;
22use trace_reads::{parse_trace_detail_or_explain, parse_trace_list_read};
23use watch::parse_watch;
24
25use crate::flags::{
26 FlagScope, is_read_filter_word, is_simple_flag, normalize_log_level, normalize_status,
27 parse_flags, validate_min_duration,
28};
29use crate::ids::{infer_explain_target, is_issue_id, is_pasted_detail_id, is_trace_id};
30use crate::{
31 CliError, Command, ExplainTarget, HelpTopic, ISSUE_STATUS_ARGUMENT_NEXT_STEP,
32 ProjectCreateOptions, ProjectSetupSeenOptions, ReadOptions, ReadTarget, SetTarget,
33 auth_namespace,
34};
35
36const HELP_NEXT_STEP: &str = "run logbrew --help";
38const READ_RESOURCE_NEXT_STEP: &str =
40 "choose one of logs, issues, actions, releases, traces, trace, issue";
41const READ_TRACE_ALIAS_NEXT_STEP: &str =
43 "use singular trace with an id: logbrew read trace <trace_id>";
44const TRACE_COMMAND_NEXT_STEP: &str =
46 "use logbrew trace <trace_id> or logbrew explain trace <trace_id>";
47const READ_TRACE_NEXT_STEP: &str = "run logbrew read trace --help";
49const READ_ISSUE_NEXT_STEP: &str = "run logbrew read issue --help";
51const READ_LOGS_NEXT_STEP: &str = "run logbrew read logs --help";
53const SEARCH_NEXT_STEP: &str = "provide search text or run logbrew logs --help";
55const READ_ISSUES_NEXT_STEP: &str = "run logbrew read issues --help";
57const READ_ACTIONS_NEXT_STEP: &str = "run logbrew read actions --help";
59const READ_RELEASES_NEXT_STEP: &str = "run logbrew read releases --help";
61const READ_TRACES_NEXT_STEP: &str = "run logbrew read traces --help";
63const PROJECTS_NEXT_STEP: &str = "run logbrew projects --help";
65const PROJECT_SETUP_SEEN_NEXT_STEP: &str = "run logbrew projects setup <project_id> --help";
67const PROJECT_SETUP_SOURCE_NEXT_STEP: &str = "use --source api, cli, or sdk";
69const WATCH_RESOURCE_NEXT_STEP: &str = "choose logs, issues, actions, or omit a resource";
71const EXPLAIN_RESOURCE_NEXT_STEP: &str = "choose issue or trace";
73const SET_RESOURCE_NEXT_STEP: &str = "choose issue";
75const TRACE_DETAIL_UNSUPPORTED_FLAGS: &[&str] = &[
77 "--name",
78 "--service",
79 "--service-name",
80 "--since",
81 "--user",
82 "--distinct-id",
83 "--trace",
84 "--trace-id",
85 "--level",
86 "--severity",
87 "--search",
88 "--status",
89 "--limit",
90 "--min-duration-ms",
91];
92const ISSUE_DETAIL_UNSUPPORTED_FLAGS: &[&str] = &[
94 "--name",
95 "--service",
96 "--service-name",
97 "--since",
98 "--user",
99 "--distinct-id",
100 "--trace",
101 "--trace-id",
102 "--level",
103 "--severity",
104 "--search",
105 "--project",
106 "--project-id",
107 "--release",
108 "--environment",
109 "--env",
110 "--status",
111 "--limit",
112 "--min-duration-ms",
113];
114const ACTION_LIST_UNSUPPORTED_FLAGS: &[&str] = &[
116 "--trace",
117 "--trace-id",
118 "--level",
119 "--severity",
120 "--search",
121 "--status",
122 "--min-duration-ms",
123];
124
125pub fn parse_command<I, S>(args: I) -> Result<Command, CliError>
128where
129 I: IntoIterator<Item = S>,
130 S: AsRef<str>,
131{
132 let values = args
133 .into_iter()
134 .map(|arg| arg.as_ref().to_owned())
135 .collect::<Vec<_>>();
136 parse_values(values.as_slice())
137}
138
139fn parse_values(values: &[String]) -> Result<Command, CliError> {
141 let args = values.get(1..).ok_or(CliError::UnknownCommand)?;
142 let Some((head, tail)) = args.split_first() else {
143 return Ok(Command::Help {
144 topic: HelpTopic::Root,
145 json: false,
146 });
147 };
148 if is_help_flag(head) {
149 validate_help_flags(tail)?;
150 ensure_no_help_positionals(positional_args(tail).as_slice())?;
151 return Ok(help_command(HelpTopic::Root, tail));
152 }
153 if head == "--json" {
154 return parse_global_json(values, tail);
155 }
156 if is_version_flag(head) {
157 return parse_version(tail);
158 }
159 if head.starts_with('-') {
160 return Err(unknown_flag(head, HELP_NEXT_STEP));
161 }
162 if head == "help" {
163 return parse_help(tail);
164 }
165 if let Some(command) = parse_literal_help(head, tail)? {
166 return Ok(command);
167 }
168 if is_setup_alias(head) && tail.iter().any(|arg| arg == "--create-project") {
169 return parse_setup_create_project(tail);
170 }
171 if contains_help_flag(tail) && !is_log_search_separator_literal(head, tail) {
172 validate_help_flags(tail)?;
173 if let Some(topic) = command_shaped_help_topic(head, tail) {
174 return Ok(help_command(topic, tail));
175 }
176 return Ok(help_command(help_topic(head, tail)?, tail));
177 }
178 match head.as_str() {
179 "login" => parse_login(tail),
180 "logout" => parse_logout(tail),
181 alias if is_setup_alias(alias) => parse_setup(tail),
182 "status" | "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 == "setup"
691 && has_position_candidate(tail)
692 {
693 return parse_project_setup_seen(tail);
694 }
695 match normalized.as_slice() {
696 [] => Ok(Command::Projects { json: false }),
697 [flag] if flag == "--json" => Ok(Command::Projects { json: true }),
698 [_, ..] => Err(CliError::InvalidProjectsCommand),
699 }
700}
701
702fn parse_project_create(args: &[String]) -> Result<Command, CliError> {
704 let Some((name, tail)) = args.split_first() else {
705 return Err(CliError::InvalidProjectCreateCommand);
706 };
707 if name.starts_with('-') {
708 return Err(CliError::InvalidProjectCreateCommand);
709 }
710 let name = bounded_project_create_value(name, 120, false)
711 .ok_or(CliError::InvalidProjectCreateCommand)?;
712 if name.starts_with('-') {
713 return Err(CliError::InvalidProjectCreateCommand);
714 }
715 let mut runtime = None;
716 let mut environment = None;
717 let mut ingest_key_file = None;
718 let mut abandon_retry = false;
719 let mut json = false;
720 let mut index = 0;
721
722 while let Some(argument) = tail.get(index) {
723 let (flag, inline_value) = argument
724 .split_once('=')
725 .map_or((argument.as_str(), None), |(flag, value)| {
726 (flag, Some(value))
727 });
728 match flag {
729 "--runtime" if runtime.is_none() => {
730 let value = project_create_flag_value(tail, &mut index, inline_value)?;
731 runtime = optional_project_create_value(value, 64)?;
732 }
733 "--environment" if environment.is_none() => {
734 let value = project_create_flag_value(tail, &mut index, inline_value)?;
735 environment = optional_project_create_value(value, 64)?;
736 }
737 "--ingest-key-file" if ingest_key_file.is_none() => {
738 let value = project_create_flag_value(tail, &mut index, inline_value)?;
739 let trimmed = value.trim();
740 if trimmed.is_empty()
741 || trimmed.len() > 4096
742 || trimmed.chars().any(char::is_control)
743 {
744 return Err(CliError::InvalidProjectCreateCommand);
745 }
746 ingest_key_file = Some(trimmed.to_owned());
747 }
748 "--abandon-retry" if inline_value.is_none() && !abandon_retry => {
749 abandon_retry = true;
750 }
751 "--json" if inline_value.is_none() && !json => json = true,
752 _ => return Err(CliError::InvalidProjectCreateCommand),
753 }
754 index += 1;
755 }
756
757 let ingest_key_file = ingest_key_file.ok_or(CliError::InvalidProjectCreateCommand)?;
758 Ok(Command::ProjectCreate {
759 options: ProjectCreateOptions {
760 name,
761 runtime,
762 environment,
763 ingest_key_file,
764 abandon_retry,
765 },
766 json,
767 })
768}
769
770fn project_create_flag_value<'a>(
772 args: &'a [String],
773 index: &mut usize,
774 inline: Option<&'a str>,
775) -> Result<&'a str, CliError> {
776 if let Some(value) = inline {
777 return Ok(value);
778 }
779 *index += 1;
780 args.get(*index)
781 .map(String::as_str)
782 .filter(|value| !value.starts_with('-'))
783 .ok_or(CliError::InvalidProjectCreateCommand)
784}
785
786fn bounded_project_create_value(value: &str, limit: usize, allow_blank: bool) -> Option<String> {
788 let value = value.trim();
789 let length = value.chars().count();
790 if value.chars().any(char::is_control) || length > limit || (!allow_blank && length == 0) {
791 return None;
792 }
793 (!value.is_empty()).then(|| value.to_owned())
794}
795
796fn optional_project_create_value(value: &str, limit: usize) -> Result<Option<String>, CliError> {
798 let trimmed = value.trim();
799 if trimmed.is_empty() {
800 return Ok(None);
801 }
802 bounded_project_create_value(trimmed, limit, false)
803 .map(Some)
804 .ok_or(CliError::InvalidProjectCreateCommand)
805}
806
807fn parse_project_setup_seen(args: &[String]) -> Result<Command, CliError> {
809 let (project_id, tail) =
810 take_required_position(args, "project_id", PROJECT_SETUP_SEEN_NEXT_STEP)?;
811 let (options, json) = parse_project_setup_seen_flags(tail.as_slice())?;
812 Ok(Command::ProjectSetupSeen {
813 project_id,
814 options,
815 json,
816 })
817}
818
819fn parse_project_setup_seen_flags(
821 args: &[String],
822) -> Result<(ProjectSetupSeenOptions, bool), CliError> {
823 let mut options = ProjectSetupSeenOptions::default();
824 let mut json = false;
825 let mut seen = Vec::new();
826 let mut index = 0;
827
828 while let Some(arg) = args.get(index) {
829 let (flag, inline_value) = split_project_setup_seen_inline_value(arg.as_str());
830 match flag {
831 "--json" if inline_value.is_none() => {
832 mark_project_setup_seen_flag(&mut seen, "--json")?;
833 json = true;
834 }
835 "--runtime" => {
836 mark_project_setup_seen_flag(&mut seen, "--runtime")?;
837 options.runtime = Some(project_setup_seen_flag_value(
838 args,
839 &mut index,
840 "--runtime",
841 inline_value,
842 )?);
843 }
844 "--source" => {
845 mark_project_setup_seen_flag(&mut seen, "--source")?;
846 options.source = Some(validate_project_setup_seen_source(
847 project_setup_seen_flag_value(args, &mut index, "--source", inline_value)?
848 .as_str(),
849 )?);
850 }
851 "--environment" | "--env" => {
852 mark_project_setup_seen_flag(&mut seen, "--environment")?;
853 let visible_flag = if flag == "--env" {
854 "--env"
855 } else {
856 "--environment"
857 };
858 options.environment = Some(project_setup_seen_flag_value(
859 args,
860 &mut index,
861 visible_flag,
862 inline_value,
863 )?);
864 }
865 flag if flag.starts_with('-') => {
866 return Err(unknown_flag(flag, PROJECT_SETUP_SEEN_NEXT_STEP));
867 }
868 argument => {
869 return Err(CliError::UnexpectedArgument {
870 argument: argument.to_owned(),
871 command: "projects setup",
872 next: PROJECT_SETUP_SEEN_NEXT_STEP,
873 });
874 }
875 }
876 index += 1;
877 }
878
879 Ok((options, json))
880}
881
882fn split_project_setup_seen_inline_value(flag: &str) -> (&str, Option<&str>) {
884 flag.split_once('=')
885 .map_or((flag, None), |(name, value)| (name, Some(value)))
886}
887
888fn mark_project_setup_seen_flag(
890 seen: &mut Vec<&'static str>,
891 flag: &'static str,
892) -> Result<(), CliError> {
893 if seen.contains(&flag) {
894 return Err(CliError::DuplicateFlag {
895 flag,
896 next: project_setup_seen_duplicate_next(flag),
897 });
898 }
899 seen.push(flag);
900 Ok(())
901}
902
903fn project_setup_seen_duplicate_next(flag: &'static str) -> &'static str {
905 match flag {
906 "--json" => "use --json once",
907 "--runtime" => "use --runtime once",
908 "--source" => "use --source once",
909 "--environment" => "use --environment once",
910 _ => "use the flag once",
911 }
912}
913
914fn project_setup_seen_flag_value(
916 args: &[String],
917 index: &mut usize,
918 flag: &'static str,
919 inline_value: Option<&str>,
920) -> Result<String, CliError> {
921 if let Some(value) = inline_value {
922 if value.is_empty() {
923 return Err(missing_project_setup_seen_flag_value(flag));
924 }
925 return Ok(value.to_owned());
926 }
927 *index += 1;
928 let Some(value) = args.get(*index) else {
929 return Err(missing_project_setup_seen_flag_value(flag));
930 };
931 if value.starts_with('-') {
932 return Err(missing_project_setup_seen_flag_value(flag));
933 }
934 Ok(value.clone())
935}
936
937fn missing_project_setup_seen_flag_value(flag: &'static str) -> CliError {
939 CliError::MissingFlagValue {
940 flag,
941 next: project_setup_seen_missing_value_next(flag),
942 }
943}
944
945fn project_setup_seen_missing_value_next(flag: &'static str) -> &'static str {
947 match flag {
948 "--runtime" => "provide a value after --runtime",
949 "--source" => PROJECT_SETUP_SOURCE_NEXT_STEP,
950 "--environment" => "provide a value after --environment",
951 "--env" => "provide a value after --env",
952 _ => "provide a value after the flag",
953 }
954}
955
956fn validate_project_setup_seen_source(source: &str) -> Result<String, CliError> {
958 match source {
959 "api" | "cli" | "sdk" => Ok(source.to_owned()),
960 other => Err(CliError::InvalidSetupSource(other.to_owned())),
961 }
962}
963
964fn parse_status(args: &[String]) -> Result<Command, CliError> {
966 let flags = parse_flags(args, FlagScope::Status)?;
967 Ok(Command::Status {
968 json: flags.is_json(),
969 })
970}
971
972fn parse_whoami(args: &[String]) -> Result<Command, CliError> {
974 let flags = parse_flags(args, FlagScope::WhoAmI)?;
975 Ok(Command::WhoAmI {
976 json: flags.is_json(),
977 })
978}
979
980fn parse_usage(args: &[String]) -> Result<Command, CliError> {
982 match args {
983 [] => Ok(Command::Usage { json: false }),
984 [flag] if flag == "--json" => Ok(Command::Usage { json: true }),
985 _ => Err(CliError::InvalidUsageCommand),
986 }
987}
988
989fn parse_doctor(args: &[String]) -> Result<Command, CliError> {
991 if args.iter().all(|arg| arg == "--json") {
992 return parse_status(args);
993 }
994
995 let mut project_id = None;
996 let mut json = false;
997 let mut index = 0;
998 while let Some(argument) = args.get(index) {
999 if let Some(value) = argument
1000 .strip_prefix("--project=")
1001 .or_else(|| argument.strip_prefix("--project-id="))
1002 {
1003 if project_id.is_some() || !crate::ids::is_uuid(value) {
1004 return Err(CliError::InvalidDoctorCommand);
1005 }
1006 project_id = Some(value.to_owned());
1007 index += 1;
1008 continue;
1009 }
1010 match argument.as_str() {
1011 "--json" if !json => json = true,
1012 "--project" | "--project-id" if project_id.is_none() => {
1013 index += 1;
1014 let Some(value) = args.get(index) else {
1015 return Err(CliError::InvalidDoctorCommand);
1016 };
1017 if !crate::ids::is_uuid(value) {
1018 return Err(CliError::InvalidDoctorCommand);
1019 }
1020 project_id = Some(value.clone());
1021 }
1022 _ => return Err(CliError::InvalidDoctorCommand),
1023 }
1024 index += 1;
1025 }
1026
1027 project_id.map_or(Err(CliError::InvalidDoctorCommand), |project_id| {
1028 Ok(Command::Doctor { project_id, json })
1029 })
1030}
1031
1032fn parse_version(args: &[String]) -> Result<Command, CliError> {
1034 let flags = parse_flags(args, FlagScope::Version)?;
1035 Ok(Command::Version {
1036 json: flags.is_json(),
1037 })
1038}
1039
1040fn take_required_arg<'a>(
1042 args: &'a [String],
1043 argument: &'static str,
1044 next: &'static str,
1045) -> Result<(&'a str, &'a [String]), CliError> {
1046 let Some((value, rest)) = args.split_first() else {
1047 return Err(CliError::MissingArgument { argument, next });
1048 };
1049 if value.starts_with('-') {
1050 return Err(CliError::MissingArgument { argument, next });
1051 }
1052 Ok((value.as_str(), rest))
1053}
1054
1055fn move_leading_json_to_tail(args: &[String]) -> Vec<String> {
1057 if args.first().is_some_and(|arg| arg == "--json") {
1058 let mut normalized = Vec::with_capacity(args.len());
1059 normalized.extend(args[1..].iter().cloned());
1060 normalized.push(String::from("--json"));
1061 normalized
1062 } else {
1063 args.to_vec()
1064 }
1065}
1066
1067fn has_position_candidate(args: &[String]) -> bool {
1069 move_leading_json_to_tail(args)
1070 .first()
1071 .is_some_and(|arg| !arg.starts_with('-'))
1072}
1073
1074fn has_trace_id_candidate(args: &[String]) -> bool {
1076 move_leading_json_to_tail(args)
1077 .first()
1078 .is_some_and(|arg| is_trace_id(arg))
1079}
1080
1081fn take_required_position(
1083 args: &[String],
1084 argument: &'static str,
1085 next: &'static str,
1086) -> Result<(String, Vec<String>), CliError> {
1087 let normalized = move_leading_json_to_tail(args);
1088 let (value, rest) = take_required_arg(normalized.as_slice(), argument, next)?;
1089 Ok((value.to_owned(), rest.to_vec()))
1090}
1091
1092fn parse_read(args: &[String]) -> Result<Command, CliError> {
1094 let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
1095 let resource = normalize_read_resource(resource.as_str());
1096 if is_recency_read_verb(resource) {
1097 return parse_read_verb(resource, rest.as_slice());
1098 }
1099 if is_known_issue_status(resource) && has_status_first_issue_resource_candidate(rest.as_slice())
1100 {
1101 return parse_status_first_issue_read(resource, rest.as_slice());
1102 }
1103 parse_read_resource(resource, rest.as_slice())
1104}
1105
1106fn normalize_read_resource(resource: &str) -> &str {
1108 match resource {
1109 "log" => "logs",
1110 "release" => "releases",
1111 _ => resource,
1112 }
1113}
1114
1115fn parse_read_verb(verb: &str, args: &[String]) -> Result<Command, CliError> {
1117 let rewritten_args = recency_count_shortcut_args(verb, args);
1118 let args = rewritten_args.as_deref().unwrap_or(args);
1119 let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
1120 if is_known_issue_status(resource.as_str())
1121 && has_status_first_issue_resource_candidate(rest.as_slice())
1122 {
1123 return parse_status_first_issue_read(resource.as_str(), rest.as_slice());
1124 }
1125 let resource = normalize_read_verb_resource(verb, resource.as_str());
1126 parse_read_resource(resource, rest.as_slice())
1127}
1128
1129fn recency_count_shortcut_args(verb: &str, args: &[String]) -> Option<Vec<String>> {
1131 if !is_recency_read_verb(verb) {
1132 return None;
1133 }
1134 let normalized = move_leading_json_to_tail(args);
1135 let (count, tail) = normalized.split_first().filter(|(count, tail)| {
1136 !tail.is_empty() && count.chars().all(|char| char.is_ascii_digit())
1137 })?;
1138 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1139 rewritten.push(tail[0].clone());
1140 let rest = &tail[1..];
1141 if let Some(separator_index) = rest.iter().position(|arg| arg == "--") {
1142 rewritten.extend(rest[..separator_index].iter().cloned());
1143 rewritten.push(String::from("--limit"));
1144 rewritten.push(count.clone());
1145 rewritten.extend(rest[separator_index..].iter().cloned());
1146 return Some(rewritten);
1147 }
1148 rewritten.extend(rest.iter().cloned());
1149 rewritten.push(String::from("--limit"));
1150 rewritten.push(count.clone());
1151 Some(rewritten)
1152}
1153
1154fn is_read_verb(value: &str) -> bool {
1156 matches!(value, "show" | "list" | "get") || is_recency_read_verb(value)
1157}
1158
1159fn is_recency_read_verb(value: &str) -> bool {
1161 matches!(value, "latest" | "recent" | "last" | "newest")
1162}
1163
1164fn normalize_read_verb_resource<'a>(verb: &str, resource: &'a str) -> &'a str {
1166 match (verb, resource) {
1167 ("list" | "show" | "get", "log") => "logs",
1168 (alias, "log") if is_recency_read_verb(alias) => "logs",
1169 (alias, "issue") if is_recency_read_verb(alias) => "issues",
1170 ("list", "issue") => "issues",
1171 ("list" | "show" | "get", "release") => "releases",
1172 (alias, "release") if is_recency_read_verb(alias) => "releases",
1173 _ => resource,
1174 }
1175}
1176
1177fn is_log_search_shortcut(command: &str) -> bool {
1179 matches!(command, "search" | "find" | "grep")
1180}
1181
1182fn is_log_search_separator_literal(command: &str, args: &[String]) -> bool {
1184 literal_log_search_separator_index(command, args).is_some()
1185}
1186
1187fn log_search_shortcut_label(command: &str) -> &'static str {
1189 match command {
1190 "find" => "find",
1191 "grep" => "grep",
1192 _ => "search",
1193 }
1194}
1195
1196fn parse_search_shortcut(label: &'static str, args: &[String]) -> Result<Command, CliError> {
1198 let (query, tail) = take_search_query(args, label)?;
1199 let mut rest = Vec::with_capacity(tail.len() + 2);
1200 if query.starts_with('-') {
1201 rest.push(format!("--search={query}"));
1202 } else {
1203 rest.push(String::from("--search"));
1204 rest.push(query);
1205 }
1206 rest.extend(tail);
1207 parse_read_resource("logs", rest.as_slice())
1208}
1209
1210fn take_search_query(
1212 args: &[String],
1213 argument: &'static str,
1214) -> Result<(String, Vec<String>), CliError> {
1215 let normalized = move_leading_json_to_tail(args);
1216 if normalized.first().is_some_and(|arg| arg == "--") {
1217 return take_separator_search_query(normalized.as_slice(), argument);
1218 }
1219 let query_word_count = normalized
1220 .iter()
1221 .take_while(|arg| !arg.starts_with('-'))
1222 .count();
1223 if query_word_count == 0 {
1224 return Err(CliError::MissingArgument {
1225 argument,
1226 next: SEARCH_NEXT_STEP,
1227 });
1228 }
1229 let query = normalized[..query_word_count].join(" ");
1230 let tail = normalized[query_word_count..].to_vec();
1231 Ok((query, tail))
1232}
1233
1234fn take_separator_search_query(
1236 args: &[String],
1237 argument: &'static str,
1238) -> Result<(String, Vec<String>), CliError> {
1239 let words = &args[1..];
1240 if words.is_empty() {
1241 return Err(CliError::MissingArgument {
1242 argument,
1243 next: SEARCH_NEXT_STEP,
1244 });
1245 }
1246 let has_trailing_json_mode = words.len() > 1 && words.last().is_some_and(|arg| arg == "--json");
1247 let query_end = if has_trailing_json_mode {
1248 words.len() - 1
1249 } else {
1250 words.len()
1251 };
1252 let query = words[..query_end].join(" ");
1253 let tail = if has_trailing_json_mode {
1254 vec![String::from("--json")]
1255 } else {
1256 Vec::new()
1257 };
1258 Ok((query, tail))
1259}
1260
1261fn parse_read_resource(resource: &str, rest: &[String]) -> Result<Command, CliError> {
1263 let (target, flags) = match resource {
1264 "logs" => parse_log_list_read(rest)?,
1265 alias if is_issue_collection_alias(alias) && has_issue_id_candidate(rest) => {
1266 return parse_issue_detail_or_status(rest);
1267 }
1268 alias if is_issue_collection_alias(alias) => parse_issue_list_read(rest)?,
1269 alias if is_action_collection_alias(alias) => parse_action_list_read(rest)?,
1270 "releases" => parse_list_read(
1271 ReadTarget::Releases,
1272 rest,
1273 "read releases",
1274 READ_RELEASES_NEXT_STEP,
1275 &[
1276 "--name",
1277 "--user",
1278 "--distinct-id",
1279 "--trace",
1280 "--trace-id",
1281 "--level",
1282 "--severity",
1283 "--search",
1284 "--status",
1285 "--min-duration-ms",
1286 ],
1287 )?,
1288 "traces" | "spans" if has_trace_id_candidate(rest) => {
1289 return parse_trace_detail_or_explain(rest);
1290 }
1291 "traces" | "spans" => parse_trace_list_read(rest)?,
1292 "trace" => return parse_trace_detail_or_explain(rest),
1293 "span" if has_position_candidate(rest) => {
1294 return parse_trace_detail_or_explain(rest);
1295 }
1296 "issue" if has_issue_status_candidate(rest) => parse_issue_list_read(rest)?,
1297 "issue" => return parse_issue_detail_or_status(rest),
1298 other => return Err(unknown_read_resource(other)),
1299 };
1300 let json = flags.is_json();
1301 let options = flags.into_read_options();
1302 validate_read_filters(&target, &options)?;
1303
1304 Ok(Command::Read {
1305 target,
1306 options: Box::new(options),
1307 json,
1308 })
1309}
1310
1311fn is_issue_collection_alias(value: &str) -> bool {
1313 matches!(
1314 value,
1315 "issues" | "errors" | "error" | "exceptions" | "exception"
1316 )
1317}
1318
1319fn is_status_first_issue_collection_alias(value: &str) -> bool {
1321 value == "issue" || is_issue_collection_alias(value)
1322}
1323
1324fn has_status_first_issue_resource_candidate(args: &[String]) -> bool {
1326 move_leading_json_to_tail(args)
1327 .first()
1328 .is_some_and(|arg| is_status_first_issue_collection_alias(arg))
1329}
1330
1331fn has_issue_status_candidate(args: &[String]) -> bool {
1333 move_leading_json_to_tail(args)
1334 .first()
1335 .is_some_and(|arg| is_known_issue_status(arg))
1336}
1337
1338fn is_action_collection_alias(value: &str) -> bool {
1340 matches!(value, "actions" | "events" | "event" | "action")
1341}
1342
1343fn parse_log_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1345 let args = log_shortcut_args(rest);
1346 parse_list_read(
1347 ReadTarget::Logs,
1348 args.as_slice(),
1349 "read logs",
1350 READ_LOGS_NEXT_STEP,
1351 &[
1352 "--name",
1353 "--user",
1354 "--distinct-id",
1355 "--status",
1356 "--min-duration-ms",
1357 ],
1358 )
1359}
1360
1361fn parse_issue_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1363 let args = issue_status_shortcut_args(rest);
1364 parse_list_read(
1365 ReadTarget::Issues,
1366 args.as_slice(),
1367 "read issues",
1368 READ_ISSUES_NEXT_STEP,
1369 &[
1370 "--name",
1371 "--user",
1372 "--distinct-id",
1373 "--trace",
1374 "--trace-id",
1375 "--level",
1376 "--severity",
1377 "--search",
1378 "--min-duration-ms",
1379 ],
1380 )
1381}
1382
1383fn parse_status_first_issue_read(status: &str, args: &[String]) -> Result<Command, CliError> {
1385 let canonical_status = normalize_status(status)?;
1386 let (resource, rest) = take_required_position(args, "resource", READ_ISSUES_NEXT_STEP)?;
1387 let resource = resource.as_str();
1388 if !is_status_first_issue_collection_alias(resource) {
1389 return Err(unknown_read_resource(resource));
1390 }
1391 let resource = if resource == "issue" {
1392 "issues"
1393 } else {
1394 resource
1395 };
1396 let mut rewritten = Vec::with_capacity(rest.len() + 2);
1397 rewritten.push(String::from("--status"));
1398 rewritten.push(canonical_status);
1399 rewritten.extend(rest);
1400 parse_read_resource(resource, rewritten.as_slice())
1401}
1402
1403fn issue_status_shortcut_args(args: &[String]) -> Vec<String> {
1405 let normalized = move_leading_json_to_tail(args);
1406 let Some((status, tail)) = normalized
1407 .split_first()
1408 .and_then(|(status, tail)| normalize_status(status).ok().map(|value| (value, tail)))
1409 else {
1410 return args.to_vec();
1411 };
1412 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1413 rewritten.push(String::from("--status"));
1414 rewritten.push(status);
1415 rewritten.extend(tail.iter().cloned());
1416 rewritten
1417}
1418
1419fn parse_action_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1421 let args = action_name_shortcut_args(rest);
1422 parse_list_read(
1423 ReadTarget::Actions,
1424 args.as_slice(),
1425 "read actions",
1426 READ_ACTIONS_NEXT_STEP,
1427 ACTION_LIST_UNSUPPORTED_FLAGS,
1428 )
1429}
1430
1431fn action_name_shortcut_args(args: &[String]) -> Vec<String> {
1433 let normalized = move_leading_json_to_tail(args);
1434 let Some((name, tail)) = normalized
1435 .split_first()
1436 .filter(|(name, _)| !name.starts_with('-') && !is_read_filter_word(name))
1437 else {
1438 return args.to_vec();
1439 };
1440 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1441 rewritten.push(String::from("--name"));
1442 rewritten.push(name.clone());
1443 rewritten.extend(tail.iter().cloned());
1444 rewritten
1445}
1446
1447fn has_issue_id_candidate(args: &[String]) -> bool {
1449 move_leading_json_to_tail(args)
1450 .first()
1451 .is_some_and(|arg| is_issue_id(arg))
1452}
1453
1454fn parse_issue_detail_or_status(args: &[String]) -> Result<Command, CliError> {
1456 let (id, tail) = take_required_position(args, "issue_id", "provide an issue id")?;
1457 if has_issue_status_action(tail.as_slice()) {
1458 return parse_issue_first_status_shortcut(id, tail.as_slice());
1459 }
1460 if let Some(command) =
1461 parse_detail_explain_suffix(ExplainTarget::Issue(id.clone()), tail.as_slice())?
1462 {
1463 return Ok(command);
1464 }
1465 let target = ReadTarget::Issue(id);
1466 let flags = parse_detail_read_flags(
1467 tail.as_slice(),
1468 "read issue",
1469 READ_ISSUE_NEXT_STEP,
1470 ISSUE_DETAIL_UNSUPPORTED_FLAGS,
1471 )?;
1472 let json = flags.is_json();
1473 let options = flags.into_read_options();
1474 validate_read_filters(&target, &options)?;
1475
1476 Ok(Command::Read {
1477 target,
1478 options: Box::new(options),
1479 json,
1480 })
1481}
1482
1483fn parse_list_read(
1485 target: ReadTarget,
1486 args: &[String],
1487 command: &'static str,
1488 next: &'static str,
1489 unsupported_flags: &[&str],
1490) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1491 reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
1492 Ok((target, parse_flags(args, FlagScope::Read)?))
1493}
1494
1495fn reject_unsupported_read_flags(
1497 args: &[String],
1498 command: &'static str,
1499 next: &'static str,
1500 unsupported_flags: &[&str],
1501) -> Result<(), CliError> {
1502 let mut index = 0;
1503 let mut seen = Vec::new();
1504 while let Some(arg) = args.get(index) {
1505 let (flag, inline_value) = arg
1506 .split_once('=')
1507 .map_or((arg.as_str(), None), |(name, value)| (name, Some(value)));
1508 if !is_read_value_flag(flag) {
1509 if flag == "--json" && inline_value.is_none() {
1510 if seen.contains(&"--json") {
1511 return Ok(());
1512 }
1513 seen.push("--json");
1514 index += 1;
1515 continue;
1516 }
1517 if inline_value.is_some() && is_simple_flag(flag) {
1518 return Err(CliError::UnsupportedFlag {
1519 flag: arg.to_owned(),
1520 command,
1521 next,
1522 });
1523 }
1524 if arg.starts_with('-') {
1525 return Err(unknown_flag(arg, next));
1526 }
1527 return Ok(());
1528 }
1529 if unsupported_flags.contains(&flag) {
1530 return Err(CliError::UnsupportedFlag {
1531 flag: user_facing_read_flag(flag).to_owned(),
1532 command,
1533 next,
1534 });
1535 }
1536 if let Some(canonical) = read_value_canonical_flag(flag) {
1537 if seen.contains(&canonical) {
1538 return Ok(());
1539 }
1540 seen.push(canonical);
1541 }
1542 if inline_value.is_some_and(str::is_empty) {
1543 return Ok(());
1544 }
1545 if inline_value.is_some_and(|value| has_invalid_supported_read_value(flag, value)) {
1546 return Ok(());
1547 }
1548 if inline_value.is_none() {
1549 let Some(value) = args.get(index + 1) else {
1550 return Ok(());
1551 };
1552 if value.starts_with('-') {
1553 return Ok(());
1554 }
1555 if has_invalid_supported_read_value(flag, value) {
1556 return Ok(());
1557 }
1558 index += 1;
1559 }
1560 index += 1;
1561 }
1562 Ok(())
1563}
1564
1565fn read_value_canonical_flag(flag: &str) -> Option<&'static str> {
1567 let canonical = match flag {
1568 "--name" => "--name",
1569 "--service" | "--service-name" => "--service",
1570 "--since" => "--since",
1571 "--user" | "--distinct-id" => "--user",
1572 "--trace" | "--trace-id" => "--trace",
1573 "--level" | "--severity" => "--severity",
1574 "--search" => "--search",
1575 "--project" | "--project-id" => "--project",
1576 "--release" => "--release",
1577 "--environment" | "--env" => "--environment",
1578 "--status" => "--status",
1579 "--limit" => "--limit",
1580 "--min-duration-ms" => "--min-duration-ms",
1581 "--pagination" => "--pagination",
1582 "--cursor-time" => "--cursor-time",
1583 "--cursor-id" => "--cursor-id",
1584 _ => return None,
1585 };
1586 Some(canonical)
1587}
1588
1589fn user_facing_read_flag(flag: &str) -> &str {
1591 match flag {
1592 "--level" => "--severity",
1593 "--service-name" => "--service",
1594 other => other,
1595 }
1596}
1597
1598fn has_invalid_supported_read_value(flag: &str, value: &str) -> bool {
1600 match flag {
1601 "--level" | "--severity" => !is_known_log_level(value),
1602 "--status" => !is_known_issue_status(value),
1603 "--limit" => value.parse::<u32>().map_or(true, |limit| limit == 0),
1604 "--min-duration-ms" => validate_min_duration(value).is_err(),
1605 "--pagination" => value != "cursor",
1606 _ => false,
1607 }
1608}
1609
1610fn is_known_log_level(value: &str) -> bool {
1612 normalize_log_level(value).is_ok()
1613}
1614
1615fn is_ambiguous_log_search_word(value: &str) -> bool {
1617 is_read_filter_word(value) || value.contains('@') || is_trace_id(value)
1618}
1619
1620fn is_known_issue_status(value: &str) -> bool {
1622 normalize_status(value).is_ok()
1623}
1624
1625fn is_read_value_flag(flag: &str) -> bool {
1627 matches!(
1628 flag,
1629 "--name"
1630 | "--service"
1631 | "--service-name"
1632 | "--since"
1633 | "--user"
1634 | "--distinct-id"
1635 | "--trace"
1636 | "--trace-id"
1637 | "--level"
1638 | "--severity"
1639 | "--search"
1640 | "--project"
1641 | "--project-id"
1642 | "--release"
1643 | "--environment"
1644 | "--env"
1645 | "--status"
1646 | "--limit"
1647 | "--min-duration-ms"
1648 | "--pagination"
1649 | "--cursor-time"
1650 | "--cursor-id"
1651 )
1652}
1653
1654fn parse_detail_explain_suffix(
1656 target: ExplainTarget,
1657 args: &[String],
1658) -> Result<Option<Command>, CliError> {
1659 let normalized = move_leading_json_to_tail(args);
1660 if normalized.first().is_none_or(|arg| arg != "explain") {
1661 return Ok(None);
1662 }
1663 Ok(Some(Command::Explain {
1664 target,
1665 json: parse_flags(&normalized[1..], FlagScope::Explain)?.is_json(),
1666 }))
1667}
1668
1669fn parse_detail_read_flags(
1671 args: &[String],
1672 command: &'static str,
1673 next: &'static str,
1674 unsupported_flags: &[&str],
1675) -> Result<crate::flags::Flags, CliError> {
1676 reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
1677 parse_flags(args, FlagScope::Read)
1678}
1679
1680fn parse_pasted_detail_id(id: &str, args: &[String]) -> Result<Command, CliError> {
1682 if is_issue_id(id) && has_issue_status_action(args) {
1683 return parse_issue_first_status_shortcut(id.to_owned(), args);
1684 }
1685 let explain_args = move_leading_json_to_tail(args);
1686 if explain_args.first().is_some_and(|arg| arg == "explain") {
1687 let target = infer_explain_target(id).ok_or_else(|| unknown_command(id))?;
1688 return Ok(Command::Explain {
1689 target,
1690 json: parse_flags(&explain_args[1..], FlagScope::Explain)?.is_json(),
1691 });
1692 }
1693 let (target, flags) = if is_trace_id(id) {
1694 (
1695 ReadTarget::Trace(id.to_owned()),
1696 parse_detail_read_flags(
1697 args,
1698 "read trace",
1699 READ_TRACE_NEXT_STEP,
1700 TRACE_DETAIL_UNSUPPORTED_FLAGS,
1701 )?,
1702 )
1703 } else if is_issue_id(id) {
1704 (
1705 ReadTarget::Issue(id.to_owned()),
1706 parse_detail_read_flags(
1707 args,
1708 "read issue",
1709 READ_ISSUE_NEXT_STEP,
1710 ISSUE_DETAIL_UNSUPPORTED_FLAGS,
1711 )?,
1712 )
1713 } else {
1714 return Err(unknown_command(id));
1715 };
1716 let json = flags.is_json();
1717 let options = flags.into_read_options();
1718 validate_read_filters(&target, &options)?;
1719
1720 Ok(Command::Read {
1721 target,
1722 options: Box::new(options),
1723 json,
1724 })
1725}
1726
1727fn validate_read_filters(target: &ReadTarget, filters: &ReadOptions) -> Result<(), CliError> {
1729 let unsupported = match target {
1730 ReadTarget::Logs => filters
1731 .first_log_unsupported_flag()
1732 .map(|flag| (flag, "read logs", READ_LOGS_NEXT_STEP)),
1733 ReadTarget::Issues => filters
1734 .first_issue_list_unsupported_flag()
1735 .map(|flag| (flag, "read issues", READ_ISSUES_NEXT_STEP)),
1736 ReadTarget::Actions => filters
1737 .first_action_unsupported_flag()
1738 .map(|flag| (flag, "read actions", READ_ACTIONS_NEXT_STEP)),
1739 ReadTarget::Releases => filters
1740 .first_release_unsupported_flag()
1741 .map(|flag| (flag, "read releases", READ_RELEASES_NEXT_STEP)),
1742 ReadTarget::Traces => filters
1743 .first_trace_list_unsupported_flag()
1744 .map(|flag| (flag, "read traces", READ_TRACES_NEXT_STEP)),
1745 ReadTarget::Trace(_) => filters
1746 .first_trace_detail_unsupported_flag()
1747 .map(|flag| (flag, "read trace", READ_TRACE_NEXT_STEP)),
1748 ReadTarget::Issue(_) => filters
1749 .first_issue_detail_unsupported_flag()
1750 .map(|flag| (flag, "read issue", READ_ISSUE_NEXT_STEP)),
1751 };
1752
1753 if let Some((flag, command, next)) = unsupported {
1754 return Err(CliError::UnsupportedFlag {
1755 flag: flag.to_owned(),
1756 command,
1757 next,
1758 });
1759 }
1760 match target {
1761 ReadTarget::Logs => validate_read_cursor(filters, CliError::InvalidLogCursor)?,
1762 ReadTarget::Actions => validate_read_cursor(filters, CliError::InvalidActionCursor)?,
1763 ReadTarget::Issues => validate_read_cursor(filters, CliError::InvalidIssueCursor)?,
1764 ReadTarget::Releases | ReadTarget::Traces | ReadTarget::Trace(_) | ReadTarget::Issue(_) => {
1765 }
1766 }
1767 Ok(())
1768}
1769
1770fn validate_read_cursor(
1772 filters: &ReadOptions,
1773 invalid_cursor: fn(String) -> CliError,
1774) -> Result<(), CliError> {
1775 match (
1776 filters.pagination.as_deref(),
1777 filters.cursor_time.as_ref(),
1778 filters.cursor_id.as_ref(),
1779 ) {
1780 (None | Some("cursor"), None, None) | (Some("cursor"), Some(_), Some(_)) => Ok(()),
1781 (None, _, _) => Err(invalid_cursor(String::from(
1782 "cursor fields require --pagination cursor",
1783 ))),
1784 (Some("cursor"), _, _) => Err(invalid_cursor(String::from(
1785 "--cursor-time and --cursor-id must be used together",
1786 ))),
1787 (Some(_), _, _) => Err(CliError::UnknownPagination),
1788 }
1789}
1790
1791fn parse_explain(args: &[String]) -> Result<Command, CliError> {
1793 let (resource, rest) = take_required_position(args, "resource", EXPLAIN_RESOURCE_NEXT_STEP)?;
1794 let (target, tail) = match resource.as_str() {
1795 "issue" => {
1796 let (id, tail) =
1797 take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1798 (ExplainTarget::Issue(id), tail)
1799 }
1800 "trace" => {
1801 let (id, tail) =
1802 take_required_position(rest.as_slice(), "trace_id", "provide a trace id")?;
1803 (ExplainTarget::Trace(id), tail)
1804 }
1805 other => {
1806 if let Some(target) = infer_explain_target(other) {
1807 (target, rest)
1808 } else {
1809 return Err(unknown_resource(other, EXPLAIN_RESOURCE_NEXT_STEP));
1810 }
1811 }
1812 };
1813 let flags = parse_flags(tail.as_slice(), FlagScope::Explain)?;
1814 Ok(Command::Explain {
1815 target,
1816 json: flags.is_json(),
1817 })
1818}
1819
1820fn parse_set(args: &[String]) -> Result<Command, CliError> {
1822 let (resource, rest) = take_required_position(args, "resource", SET_RESOURCE_NEXT_STEP)?;
1823 if resource != "issue" {
1824 return Err(unknown_resource(resource.as_str(), SET_RESOURCE_NEXT_STEP));
1825 }
1826 let (id, rest) = take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1827 let (status, tail) =
1828 take_required_position(rest.as_slice(), "status", ISSUE_STATUS_ARGUMENT_NEXT_STEP)?;
1829 let status = normalize_status(status.as_str())?;
1830 let flags = parse_flags(tail.as_slice(), FlagScope::Set)?;
1831
1832 Ok(Command::Set {
1833 target: SetTarget::IssueStatus { id, status },
1834 json: flags.is_json(),
1835 })
1836}