1mod help_topics;
4mod issue_shortcuts;
5mod log_shortcuts;
6mod support;
7mod trace_reads;
8mod watch;
9
10use help_topics::{
11 command_shaped_help_topic, contains_help_flag, ensure_no_help_positionals, help_command,
12 help_topic, is_direct_filter_help_alias, is_help_flag, parse_help, parse_help_alias,
13 parse_literal_help, positional_args, validate_help_flags,
14};
15use issue_shortcuts::{
16 has_issue_status_action, is_issue_status_action_alias, parse_bare_issue_status_shortcut,
17 parse_issue_first_status_shortcut, parse_issue_status_shortcut,
18 parse_status_first_issue_id_shortcut,
19};
20use log_shortcuts::{literal_log_search_separator_index, log_shortcut_args};
21use support::parse_support;
22use trace_reads::{parse_trace_detail_or_explain, parse_trace_list_read};
23use watch::parse_watch;
24
25use crate::flags::{
26 FlagScope, is_read_filter_word, is_simple_flag, normalize_log_level, normalize_status,
27 parse_flags, validate_min_duration,
28};
29use crate::ids::{infer_explain_target, is_issue_id, is_pasted_detail_id, is_trace_id};
30use crate::{
31 CliError, Command, ExplainTarget, HelpTopic, ISSUE_STATUS_ARGUMENT_NEXT_STEP,
32 ProjectCreateOptions, ProjectSetupSeenOptions, ReadOptions, ReadTarget, SetTarget,
33 auth_namespace,
34};
35
36const HELP_NEXT_STEP: &str = "run logbrew --help";
38const READ_RESOURCE_NEXT_STEP: &str =
40 "choose one of logs, issues, actions, releases, traces, trace, issue";
41const READ_TRACE_ALIAS_NEXT_STEP: &str =
43 "use singular trace with an id: logbrew read trace <trace_id>";
44const TRACE_COMMAND_NEXT_STEP: &str =
46 "use logbrew trace <trace_id> or logbrew explain trace <trace_id>";
47const READ_TRACE_NEXT_STEP: &str = "run logbrew read trace --help";
49const READ_ISSUE_NEXT_STEP: &str = "run logbrew read issue --help";
51const READ_LOGS_NEXT_STEP: &str = "run logbrew read logs --help";
53const SEARCH_NEXT_STEP: &str = "provide search text or run logbrew logs --help";
55const READ_ISSUES_NEXT_STEP: &str = "run logbrew read issues --help";
57const READ_ACTIONS_NEXT_STEP: &str = "run logbrew read actions --help";
59const READ_RELEASES_NEXT_STEP: &str = "run logbrew read releases --help";
61const READ_TRACES_NEXT_STEP: &str = "run logbrew read traces --help";
63const PROJECTS_NEXT_STEP: &str = "run logbrew projects --help";
65const PROJECT_SETUP_SEEN_NEXT_STEP: &str = "run logbrew projects setup <project_id> --help";
67const PROJECT_SETUP_SOURCE_NEXT_STEP: &str = "use --source api, cli, or sdk";
69const WATCH_RESOURCE_NEXT_STEP: &str = "choose logs, issues, actions, or omit a resource";
71const EXPLAIN_RESOURCE_NEXT_STEP: &str = "choose issue or trace";
73const SET_RESOURCE_NEXT_STEP: &str = "choose issue";
75const TRACE_DETAIL_UNSUPPORTED_FLAGS: &[&str] = &[
77 "--name",
78 "--service",
79 "--service-name",
80 "--since",
81 "--user",
82 "--distinct-id",
83 "--trace",
84 "--trace-id",
85 "--level",
86 "--severity",
87 "--search",
88 "--status",
89 "--limit",
90 "--min-duration-ms",
91];
92const ISSUE_DETAIL_UNSUPPORTED_FLAGS: &[&str] = &[
94 "--name",
95 "--service",
96 "--service-name",
97 "--since",
98 "--user",
99 "--distinct-id",
100 "--trace",
101 "--trace-id",
102 "--level",
103 "--severity",
104 "--search",
105 "--project",
106 "--project-id",
107 "--release",
108 "--environment",
109 "--env",
110 "--status",
111 "--limit",
112 "--min-duration-ms",
113];
114const ACTION_LIST_UNSUPPORTED_FLAGS: &[&str] = &[
116 "--trace",
117 "--trace-id",
118 "--level",
119 "--severity",
120 "--search",
121 "--status",
122 "--min-duration-ms",
123];
124
125pub fn parse_command<I, S>(args: I) -> Result<Command, CliError>
128where
129 I: IntoIterator<Item = S>,
130 S: AsRef<str>,
131{
132 let values = args
133 .into_iter()
134 .map(|arg| arg.as_ref().to_owned())
135 .collect::<Vec<_>>();
136 parse_values(values.as_slice())
137}
138
139fn parse_values(values: &[String]) -> Result<Command, CliError> {
141 let args = values.get(1..).ok_or(CliError::UnknownCommand)?;
142 let Some((head, tail)) = args.split_first() else {
143 return Ok(Command::Help {
144 topic: HelpTopic::Root,
145 json: false,
146 });
147 };
148 if is_help_flag(head) {
149 validate_help_flags(tail)?;
150 ensure_no_help_positionals(positional_args(tail).as_slice())?;
151 return Ok(help_command(HelpTopic::Root, tail));
152 }
153 if head == "--json" {
154 return parse_global_json(values, tail);
155 }
156 if is_version_flag(head) {
157 return parse_version(tail);
158 }
159 if head.starts_with('-') {
160 return Err(unknown_flag(head, HELP_NEXT_STEP));
161 }
162 if head == "help" {
163 return parse_help(tail);
164 }
165 if let Some(command) = parse_literal_help(head, tail)? {
166 return Ok(command);
167 }
168 if is_setup_alias(head) && tail.iter().any(|arg| arg == "--create-project") {
169 return parse_setup_create_project(tail);
170 }
171 if contains_help_flag(tail) && !is_log_search_separator_literal(head, tail) {
172 validate_help_flags(tail)?;
173 if let Some(topic) = command_shaped_help_topic(head, tail) {
174 return Ok(help_command(topic, tail));
175 }
176 return Ok(help_command(help_topic(head, tail)?, tail));
177 }
178 match head.as_str() {
179 "login" => parse_login(tail),
180 "logout" => parse_logout(tail),
181 alias if is_setup_alias(alias) => parse_setup(tail),
182 "status" | "whoami" | "me" | "health" | "ping" => parse_status(tail),
183 "doctor" => parse_doctor(tail),
184 "version" => parse_version(tail),
185 "account" if tail.first().is_some_and(|arg| arg == "usage") => parse_usage(&tail[1..]),
186 alias if auth_namespace::is_namespace(alias) => auth_namespace::parse(tail),
187 alias if auth_namespace::is_help_alias(alias) => parse_help_alias(HelpTopic::Auth, tail),
188 "json" | "output" => parse_help_alias(HelpTopic::Json, tail),
189 alias if is_examples_help_alias(alias) => parse_help_alias(HelpTopic::Examples, tail),
190 alias if is_project_help_alias(alias) => parse_project(tail),
191 "usage" => parse_usage(tail),
192 "support" => parse_support(tail),
193 "investigate" => parse_investigate(tail),
194 "debug-artifacts" => parse_native_debug_artifacts(tail),
195 alias if is_direct_filter_help_alias(alias) => parse_help_alias(HelpTopic::Read, tail),
196 "read" => parse_read(tail),
197 alias if is_read_verb(alias) => parse_read_verb(alias, tail),
198 status if is_known_issue_status(status) && has_issue_id_candidate(tail) => {
199 parse_status_first_issue_id_shortcut(status, tail)
200 }
201 status
202 if is_known_issue_status(status) && has_status_first_issue_resource_candidate(tail) =>
203 {
204 parse_status_first_issue_read(status, tail)
205 }
206 status if is_known_issue_status(status) => parse_bare_issue_status_shortcut(status, tail),
207 alias if is_log_search_shortcut(alias) => {
208 parse_search_shortcut(log_search_shortcut_label(alias), tail)
209 }
210 "log" => parse_read_resource("logs", tail),
211 "release" => parse_read_resource("releases", tail),
212 alias if matches!(alias, "trace" | "span") && !has_position_candidate(tail) => {
213 parse_help_alias(HelpTopic::ReadTrace, tail)
214 }
215 alias if matches!(alias, "traces" | "spans") && has_trace_id_candidate(tail) => {
216 parse_read_resource("trace", tail)
217 }
218 "traces" | "spans" => parse_read_resource("traces", tail),
219 "logs" | "issues" | "errors" | "error" | "exceptions" | "exception" | "actions"
220 | "events" | "event" | "action" | "releases" | "trace" | "issue" => {
221 parse_read_resource(head, tail)
222 }
223 "span" if has_position_candidate(tail) => parse_read_resource("trace", tail),
224 "resolve" | "close" | "ignore" | "reopen" => parse_issue_status_shortcut(head, tail),
225 alias if is_watch_command_alias(alias) => parse_watch(tail),
226 "explain" => parse_explain(tail),
227 "set" => parse_set(tail),
228 id if is_pasted_detail_id(id) => parse_pasted_detail_id(id, tail),
229 _ => Err(unknown_command(head)),
230 }
231}
232
233fn parse_native_debug_artifacts(args: &[String]) -> Result<Command, CliError> {
235 let normalized = move_leading_json_to_tail(args);
236 let Some((operation, tail)) = normalized.split_first() else {
237 return Err(CliError::InvalidNativeDebugCommand);
238 };
239 match operation.as_str() {
240 "upload" => parse_native_debug_upload(tail),
241 "lookup" => parse_native_debug_lookup(tail),
242 _ => Err(CliError::InvalidNativeDebugCommand),
243 }
244}
245
246fn parse_native_debug_upload(args: &[String]) -> Result<Command, CliError> {
248 let Some((path, flags)) = args.split_first() else {
249 return Err(CliError::InvalidNativeDebugCommand);
250 };
251 if path.is_empty() || path.chars().any(char::is_control) || path.starts_with('-') {
252 return Err(CliError::InvalidNativeDebugCommand);
253 }
254 let parsed = parse_native_debug_scope(flags, false)?;
255 Ok(Command::NativeDebugArtifacts {
256 target: crate::NativeDebugArtifactsTarget::Upload(crate::NativeDebugUploadOptions {
257 path: path.clone(),
258 project_id: parsed.project_id,
259 release: parsed.release,
260 environment: parsed.environment,
261 service: parsed.service,
262 expected_image_uuids: parsed.expected_image_uuids,
263 dry_run: parsed.dry_run,
264 }),
265 json: parsed.json,
266 })
267}
268
269fn parse_native_debug_lookup(args: &[String]) -> Result<Command, CliError> {
271 let parsed = parse_native_debug_scope(args, true)?;
272 let image_uuid = parsed
273 .image_uuid
274 .and_then(|value| normalize_native_debug_uuid(value.as_str()))
275 .ok_or(CliError::InvalidNativeDebugIdentity)?;
276 let architecture = parsed
277 .architecture
278 .filter(|value| matches!(value.as_str(), "arm64" | "arm64e" | "x86_64"))
279 .ok_or(CliError::InvalidNativeDebugIdentity)?;
280 Ok(Command::NativeDebugArtifacts {
281 target: crate::NativeDebugArtifactsTarget::Lookup(crate::NativeDebugLookupOptions {
282 project_id: parsed.project_id,
283 release: parsed.release,
284 environment: parsed.environment,
285 service: parsed.service,
286 image_uuid,
287 architecture,
288 }),
289 json: parsed.json,
290 })
291}
292
293#[derive(Default)]
295struct NativeDebugScope {
296 project_id: String,
298 release: String,
300 environment: String,
302 service: String,
304 image_uuid: Option<String>,
306 expected_image_uuids: Vec<String>,
308 architecture: Option<String>,
310 dry_run: bool,
312 json: bool,
314}
315
316fn parse_native_debug_scope(args: &[String], lookup: bool) -> Result<NativeDebugScope, CliError> {
318 let mut project_id = None;
319 let mut release = None;
320 let mut environment = None;
321 let mut service = None;
322 let mut image_uuid = None;
323 let mut expected_image_uuids = Vec::new();
324 let mut architecture = None;
325 let mut dry_run = false;
326 let mut json = false;
327 let mut index = 0;
328 while let Some(flag) = args.get(index) {
329 if flag == "--json" {
330 if json {
331 return Err(CliError::InvalidNativeDebugCommand);
332 }
333 json = true;
334 index += 1;
335 continue;
336 }
337 if flag == "--dry-run" && !lookup {
338 if dry_run {
339 return Err(CliError::InvalidNativeDebugCommand);
340 }
341 dry_run = true;
342 index += 1;
343 continue;
344 }
345 if flag == "--expect-image-uuid" && !lookup {
346 let value = args
347 .get(index + 1)
348 .and_then(|value| normalize_native_debug_uuid(value.as_str()))
349 .ok_or(CliError::InvalidNativeDebugIdentity)?;
350 if expected_image_uuids
351 .iter()
352 .any(|existing| existing == &value)
353 {
354 return Err(CliError::InvalidNativeDebugIdentity);
355 }
356 expected_image_uuids.push(value);
357 index += 2;
358 continue;
359 }
360 let destination = match flag.as_str() {
361 "--project" => &mut project_id,
362 "--release" => &mut release,
363 "--environment" => &mut environment,
364 "--service" => &mut service,
365 "--image-uuid" if lookup => &mut image_uuid,
366 "--architecture" if lookup => &mut architecture,
367 _ => return Err(CliError::InvalidNativeDebugCommand),
368 };
369 if destination.is_some() {
370 return Err(CliError::InvalidNativeDebugCommand);
371 }
372 let value = args
373 .get(index + 1)
374 .ok_or(CliError::InvalidNativeDebugCommand)?;
375 *destination = Some(value.clone());
376 index += 2;
377 }
378
379 let project_id = project_id
380 .filter(|value| is_canonical_lower_uuid(value))
381 .ok_or(CliError::InvalidNativeDebugCommand)?;
382 let release = normalize_native_scope(release).ok_or(CliError::InvalidNativeDebugCommand)?;
383 let environment =
384 normalize_native_scope(environment).ok_or(CliError::InvalidNativeDebugCommand)?;
385 let service = normalize_native_scope(service).ok_or(CliError::InvalidNativeDebugCommand)?;
386 if lookup != (image_uuid.is_some() && architecture.is_some()) {
387 return Err(CliError::InvalidNativeDebugCommand);
388 }
389 if lookup && (!expected_image_uuids.is_empty() || dry_run) {
390 return Err(CliError::InvalidNativeDebugCommand);
391 }
392 expected_image_uuids.sort();
393 Ok(NativeDebugScope {
394 project_id,
395 release,
396 environment,
397 service,
398 image_uuid,
399 expected_image_uuids,
400 architecture,
401 dry_run,
402 json,
403 })
404}
405
406fn normalize_native_scope(value: Option<String>) -> Option<String> {
408 let value = value?;
409 let trimmed = value.trim();
410 (!trimmed.is_empty() && trimmed.len() <= 256 && !trimmed.chars().any(char::is_control))
411 .then(|| trimmed.to_owned())
412}
413
414fn normalize_native_debug_uuid(value: &str) -> Option<String> {
416 if value.len() != 36 {
417 return None;
418 }
419 let mut normalized = String::with_capacity(36);
420 for (index, byte) in value.bytes().enumerate() {
421 if matches!(index, 8 | 13 | 18 | 23) {
422 if byte != b'-' {
423 return None;
424 }
425 normalized.push('-');
426 } else if byte.is_ascii_hexdigit() {
427 normalized.push(char::from(byte.to_ascii_lowercase()));
428 } else {
429 return None;
430 }
431 }
432 Some(normalized)
433}
434
435fn is_canonical_lower_uuid(value: &str) -> bool {
437 value.len() == 36
438 && value.bytes().enumerate().all(|(index, byte)| {
439 matches!(index, 8 | 13 | 18 | 23) && byte == b'-'
440 || !matches!(index, 8 | 13 | 18 | 23)
441 && (byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
442 })
443}
444
445fn parse_investigate(args: &[String]) -> Result<Command, CliError> {
447 let normalized = move_leading_json_to_tail(args);
448 match normalized.as_slice() {
449 [resource, issue_id]
450 if resource == "issue" && is_safe_investigation_issue_id(issue_id.as_str()) =>
451 {
452 Ok(Command::InvestigateIssue {
453 issue_id: issue_id.clone(),
454 json: false,
455 })
456 }
457 [resource, issue_id, json]
458 if resource == "issue"
459 && is_safe_investigation_issue_id(issue_id.as_str())
460 && json == "--json" =>
461 {
462 Ok(Command::InvestigateIssue {
463 issue_id: issue_id.clone(),
464 json: true,
465 })
466 }
467 _ => Err(CliError::InvalidInvestigationCommand),
468 }
469}
470
471fn is_safe_investigation_issue_id(value: &str) -> bool {
473 value.len() == 36
474 && value.bytes().enumerate().all(|(index, byte)| {
475 matches!(index, 8 | 13 | 18 | 23) && byte == b'-'
476 || !matches!(index, 8 | 13 | 18 | 23)
477 && (byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
478 })
479}
480
481fn parse_global_json(values: &[String], tail: &[String]) -> Result<Command, CliError> {
483 if global_json_tail_has_duplicate(tail) {
484 return Err(CliError::DuplicateFlag {
485 flag: "--json",
486 next: "use --json once",
487 });
488 }
489 if tail.is_empty() {
490 return Ok(Command::Help {
491 topic: HelpTopic::Root,
492 json: true,
493 });
494 }
495
496 let mut normalized = Vec::with_capacity(values.len());
497 if let Some(program) = values.first() {
498 normalized.push(program.clone());
499 }
500 normalized.extend(tail.iter().cloned());
501 normalized.push(String::from("--json"));
502 parse_values(normalized.as_slice())
503}
504
505fn global_json_tail_has_duplicate(tail: &[String]) -> bool {
507 if !tail.iter().any(|arg| arg == "--json") {
508 return false;
509 }
510 let Some((command, rest)) = tail.split_first() else {
511 return false;
512 };
513 let Some(separator_index) = literal_log_search_separator_index(command, rest) else {
514 return true;
515 };
516 rest[..separator_index].iter().any(|arg| arg == "--json")
517}
518fn unknown_resource(resource: &str, next: &'static str) -> CliError {
520 CliError::UnknownResource {
521 resource: resource.to_owned(),
522 next,
523 }
524}
525
526fn unknown_flag(flag: &str, next: &'static str) -> CliError {
528 CliError::UnknownFlag {
529 flag: flag.to_owned(),
530 next,
531 }
532}
533
534fn unknown_read_resource(resource: &str) -> CliError {
536 unknown_resource(resource, read_resource_next_step(resource))
537}
538
539fn read_resource_next_step(resource: &str) -> &'static str {
541 match resource {
542 "trace" | "traces" | "span" | "spans" => READ_TRACE_ALIAS_NEXT_STEP,
543 _ => READ_RESOURCE_NEXT_STEP,
544 }
545}
546
547fn unknown_command(command: &str) -> CliError {
549 CliError::UnknownCommandName {
550 command: command.to_owned(),
551 next: unknown_command_next_step(command),
552 }
553}
554
555fn unknown_command_next_step(command: &str) -> &'static str {
557 match command {
558 "logg" | "lgs" => "did you mean logbrew logs?",
559 "action" | "event" | "events" => "did you mean logbrew actions?",
560 "releaze" | "rels" => "did you mean logbrew releases?",
561 "statuz" | "stats" => "did you mean logbrew status?",
562 "error" | "errors" | "exception" | "exceptions" => "did you mean logbrew issues?",
563 "trace" | "traces" | "span" | "spans" => TRACE_COMMAND_NEXT_STEP,
564 "env" | "environment" | "environments" => {
565 "use --environment <environment> with logs, issues, actions, releases, or traces"
566 }
567 alias if auth_namespace::is_help_alias(alias) => "run logbrew help auth",
568 _ => HELP_NEXT_STEP,
569 }
570}
571
572fn is_status_help_alias(value: &str) -> bool {
574 matches!(value, "status" | "health" | "ping" | "doctor")
575}
576
577fn is_examples_help_alias(value: &str) -> bool {
579 matches!(
580 value,
581 "example" | "examples" | "sample" | "samples" | "recipe" | "recipes"
582 )
583}
584
585fn is_setup_alias(value: &str) -> bool {
587 matches!(value, "setup" | "init" | "install" | "configure" | "sdk")
588}
589
590fn is_project_help_alias(value: &str) -> bool {
592 matches!(value, "project" | "projects")
593}
594
595fn is_watch_command_alias(value: &str) -> bool {
597 matches!(value, "watch" | "tail" | "follow" | "stream")
598}
599
600fn is_version_flag(value: &str) -> bool {
602 matches!(value, "--version" | "-V")
603}
604
605fn parse_login(args: &[String]) -> Result<Command, CliError> {
607 let flags = parse_flags(args, FlagScope::Login)?;
608 let json = flags.is_json();
609 Ok(Command::Login {
610 open_browser: flags.should_open_browser() && !json,
611 json,
612 })
613}
614
615fn parse_logout(args: &[String]) -> Result<Command, CliError> {
617 let flags = parse_flags(args, FlagScope::Logout)?;
618 Ok(Command::Logout {
619 json: flags.is_json(),
620 })
621}
622
623fn parse_setup(args: &[String]) -> Result<Command, CliError> {
625 if args.iter().any(|arg| arg == "--create-project") {
626 return parse_setup_create_project(args);
627 }
628 let flags = parse_flags(args, FlagScope::Setup)?;
629 Ok(Command::Setup {
630 auto: flags.is_auto(),
631 yes: flags.skip_prompts(),
632 json: flags.is_json(),
633 })
634}
635
636fn parse_setup_create_project(args: &[String]) -> Result<Command, CliError> {
638 let mut seen_create_project = false;
639 let mut seen_json = false;
640
641 for arg in args {
642 match arg.as_str() {
643 "--create-project" => {
644 if std::mem::replace(&mut seen_create_project, true) {
645 return Err(CliError::DuplicateFlag {
646 flag: "--create-project",
647 next: "use --create-project once",
648 });
649 }
650 }
651 "--json" => {
652 if std::mem::replace(&mut seen_json, true) {
653 return Err(CliError::DuplicateFlag {
654 flag: "--json",
655 next: "use --json once",
656 });
657 }
658 }
659 "--help" | "-h" => {}
660 flag if flag.starts_with('-') => {
661 return Err(unknown_flag(flag, PROJECTS_NEXT_STEP));
662 }
663 argument => {
664 return Err(CliError::UnexpectedArgument {
665 argument: argument.to_owned(),
666 command: "setup",
667 next: PROJECTS_NEXT_STEP,
668 });
669 }
670 }
671 }
672
673 Ok(Command::Help {
674 topic: HelpTopic::Projects,
675 json: seen_json,
676 })
677}
678
679fn parse_project(args: &[String]) -> Result<Command, CliError> {
681 let normalized = move_leading_json_to_tail(args);
682 if let Some((subcommand, tail)) = normalized.split_first()
683 && subcommand == "create"
684 {
685 return parse_project_create(tail);
686 }
687 if let Some((subcommand, tail)) = normalized.split_first()
688 && subcommand == "setup"
689 && has_position_candidate(tail)
690 {
691 return parse_project_setup_seen(tail);
692 }
693 match normalized.as_slice() {
694 [] => Ok(Command::Projects { json: false }),
695 [flag] if flag == "--json" => Ok(Command::Projects { json: true }),
696 [_, ..] => Err(CliError::InvalidProjectsCommand),
697 }
698}
699
700fn parse_project_create(args: &[String]) -> Result<Command, CliError> {
702 let Some((name, tail)) = args.split_first() else {
703 return Err(CliError::InvalidProjectCreateCommand);
704 };
705 if name.starts_with('-') {
706 return Err(CliError::InvalidProjectCreateCommand);
707 }
708 let name = bounded_project_create_value(name, 120, false)
709 .ok_or(CliError::InvalidProjectCreateCommand)?;
710 if name.starts_with('-') {
711 return Err(CliError::InvalidProjectCreateCommand);
712 }
713 let mut runtime = None;
714 let mut environment = None;
715 let mut ingest_key_file = None;
716 let mut abandon_retry = false;
717 let mut json = false;
718 let mut index = 0;
719
720 while let Some(argument) = tail.get(index) {
721 let (flag, inline_value) = argument
722 .split_once('=')
723 .map_or((argument.as_str(), None), |(flag, value)| {
724 (flag, Some(value))
725 });
726 match flag {
727 "--runtime" if runtime.is_none() => {
728 let value = project_create_flag_value(tail, &mut index, inline_value)?;
729 runtime = optional_project_create_value(value, 64)?;
730 }
731 "--environment" if environment.is_none() => {
732 let value = project_create_flag_value(tail, &mut index, inline_value)?;
733 environment = optional_project_create_value(value, 64)?;
734 }
735 "--ingest-key-file" if ingest_key_file.is_none() => {
736 let value = project_create_flag_value(tail, &mut index, inline_value)?;
737 let trimmed = value.trim();
738 if trimmed.is_empty()
739 || trimmed.len() > 4096
740 || trimmed.chars().any(char::is_control)
741 {
742 return Err(CliError::InvalidProjectCreateCommand);
743 }
744 ingest_key_file = Some(trimmed.to_owned());
745 }
746 "--abandon-retry" if inline_value.is_none() && !abandon_retry => {
747 abandon_retry = true;
748 }
749 "--json" if inline_value.is_none() && !json => json = true,
750 _ => return Err(CliError::InvalidProjectCreateCommand),
751 }
752 index += 1;
753 }
754
755 let ingest_key_file = ingest_key_file.ok_or(CliError::InvalidProjectCreateCommand)?;
756 Ok(Command::ProjectCreate {
757 options: ProjectCreateOptions {
758 name,
759 runtime,
760 environment,
761 ingest_key_file,
762 abandon_retry,
763 },
764 json,
765 })
766}
767
768fn project_create_flag_value<'a>(
770 args: &'a [String],
771 index: &mut usize,
772 inline: Option<&'a str>,
773) -> Result<&'a str, CliError> {
774 if let Some(value) = inline {
775 return Ok(value);
776 }
777 *index += 1;
778 args.get(*index)
779 .map(String::as_str)
780 .filter(|value| !value.starts_with('-'))
781 .ok_or(CliError::InvalidProjectCreateCommand)
782}
783
784fn bounded_project_create_value(value: &str, limit: usize, allow_blank: bool) -> Option<String> {
786 let value = value.trim();
787 let length = value.chars().count();
788 if value.chars().any(char::is_control) || length > limit || (!allow_blank && length == 0) {
789 return None;
790 }
791 (!value.is_empty()).then(|| value.to_owned())
792}
793
794fn optional_project_create_value(value: &str, limit: usize) -> Result<Option<String>, CliError> {
796 let trimmed = value.trim();
797 if trimmed.is_empty() {
798 return Ok(None);
799 }
800 bounded_project_create_value(trimmed, limit, false)
801 .map(Some)
802 .ok_or(CliError::InvalidProjectCreateCommand)
803}
804
805fn parse_project_setup_seen(args: &[String]) -> Result<Command, CliError> {
807 let (project_id, tail) =
808 take_required_position(args, "project_id", PROJECT_SETUP_SEEN_NEXT_STEP)?;
809 let (options, json) = parse_project_setup_seen_flags(tail.as_slice())?;
810 Ok(Command::ProjectSetupSeen {
811 project_id,
812 options,
813 json,
814 })
815}
816
817fn parse_project_setup_seen_flags(
819 args: &[String],
820) -> Result<(ProjectSetupSeenOptions, bool), CliError> {
821 let mut options = ProjectSetupSeenOptions::default();
822 let mut json = false;
823 let mut seen = Vec::new();
824 let mut index = 0;
825
826 while let Some(arg) = args.get(index) {
827 let (flag, inline_value) = split_project_setup_seen_inline_value(arg.as_str());
828 match flag {
829 "--json" if inline_value.is_none() => {
830 mark_project_setup_seen_flag(&mut seen, "--json")?;
831 json = true;
832 }
833 "--runtime" => {
834 mark_project_setup_seen_flag(&mut seen, "--runtime")?;
835 options.runtime = Some(project_setup_seen_flag_value(
836 args,
837 &mut index,
838 "--runtime",
839 inline_value,
840 )?);
841 }
842 "--source" => {
843 mark_project_setup_seen_flag(&mut seen, "--source")?;
844 options.source = Some(validate_project_setup_seen_source(
845 project_setup_seen_flag_value(args, &mut index, "--source", inline_value)?
846 .as_str(),
847 )?);
848 }
849 "--environment" | "--env" => {
850 mark_project_setup_seen_flag(&mut seen, "--environment")?;
851 let visible_flag = if flag == "--env" {
852 "--env"
853 } else {
854 "--environment"
855 };
856 options.environment = Some(project_setup_seen_flag_value(
857 args,
858 &mut index,
859 visible_flag,
860 inline_value,
861 )?);
862 }
863 flag if flag.starts_with('-') => {
864 return Err(unknown_flag(flag, PROJECT_SETUP_SEEN_NEXT_STEP));
865 }
866 argument => {
867 return Err(CliError::UnexpectedArgument {
868 argument: argument.to_owned(),
869 command: "projects setup",
870 next: PROJECT_SETUP_SEEN_NEXT_STEP,
871 });
872 }
873 }
874 index += 1;
875 }
876
877 Ok((options, json))
878}
879
880fn split_project_setup_seen_inline_value(flag: &str) -> (&str, Option<&str>) {
882 flag.split_once('=')
883 .map_or((flag, None), |(name, value)| (name, Some(value)))
884}
885
886fn mark_project_setup_seen_flag(
888 seen: &mut Vec<&'static str>,
889 flag: &'static str,
890) -> Result<(), CliError> {
891 if seen.contains(&flag) {
892 return Err(CliError::DuplicateFlag {
893 flag,
894 next: project_setup_seen_duplicate_next(flag),
895 });
896 }
897 seen.push(flag);
898 Ok(())
899}
900
901fn project_setup_seen_duplicate_next(flag: &'static str) -> &'static str {
903 match flag {
904 "--json" => "use --json once",
905 "--runtime" => "use --runtime once",
906 "--source" => "use --source once",
907 "--environment" => "use --environment once",
908 _ => "use the flag once",
909 }
910}
911
912fn project_setup_seen_flag_value(
914 args: &[String],
915 index: &mut usize,
916 flag: &'static str,
917 inline_value: Option<&str>,
918) -> Result<String, CliError> {
919 if let Some(value) = inline_value {
920 if value.is_empty() {
921 return Err(missing_project_setup_seen_flag_value(flag));
922 }
923 return Ok(value.to_owned());
924 }
925 *index += 1;
926 let Some(value) = args.get(*index) else {
927 return Err(missing_project_setup_seen_flag_value(flag));
928 };
929 if value.starts_with('-') {
930 return Err(missing_project_setup_seen_flag_value(flag));
931 }
932 Ok(value.clone())
933}
934
935fn missing_project_setup_seen_flag_value(flag: &'static str) -> CliError {
937 CliError::MissingFlagValue {
938 flag,
939 next: project_setup_seen_missing_value_next(flag),
940 }
941}
942
943fn project_setup_seen_missing_value_next(flag: &'static str) -> &'static str {
945 match flag {
946 "--runtime" => "provide a value after --runtime",
947 "--source" => PROJECT_SETUP_SOURCE_NEXT_STEP,
948 "--environment" => "provide a value after --environment",
949 "--env" => "provide a value after --env",
950 _ => "provide a value after the flag",
951 }
952}
953
954fn validate_project_setup_seen_source(source: &str) -> Result<String, CliError> {
956 match source {
957 "api" | "cli" | "sdk" => Ok(source.to_owned()),
958 other => Err(CliError::InvalidSetupSource(other.to_owned())),
959 }
960}
961
962fn parse_status(args: &[String]) -> Result<Command, CliError> {
964 let flags = parse_flags(args, FlagScope::Status)?;
965 Ok(Command::Status {
966 json: flags.is_json(),
967 })
968}
969
970fn parse_usage(args: &[String]) -> Result<Command, CliError> {
972 match args {
973 [] => Ok(Command::Usage { json: false }),
974 [flag] if flag == "--json" => Ok(Command::Usage { json: true }),
975 _ => Err(CliError::InvalidUsageCommand),
976 }
977}
978
979fn parse_doctor(args: &[String]) -> Result<Command, CliError> {
981 if args.iter().all(|arg| arg == "--json") {
982 return parse_status(args);
983 }
984
985 let mut project_id = None;
986 let mut json = false;
987 let mut index = 0;
988 while let Some(argument) = args.get(index) {
989 if let Some(value) = argument
990 .strip_prefix("--project=")
991 .or_else(|| argument.strip_prefix("--project-id="))
992 {
993 if project_id.is_some() || !crate::ids::is_uuid(value) {
994 return Err(CliError::InvalidDoctorCommand);
995 }
996 project_id = Some(value.to_owned());
997 index += 1;
998 continue;
999 }
1000 match argument.as_str() {
1001 "--json" if !json => json = true,
1002 "--project" | "--project-id" if project_id.is_none() => {
1003 index += 1;
1004 let Some(value) = args.get(index) else {
1005 return Err(CliError::InvalidDoctorCommand);
1006 };
1007 if !crate::ids::is_uuid(value) {
1008 return Err(CliError::InvalidDoctorCommand);
1009 }
1010 project_id = Some(value.clone());
1011 }
1012 _ => return Err(CliError::InvalidDoctorCommand),
1013 }
1014 index += 1;
1015 }
1016
1017 project_id.map_or(Err(CliError::InvalidDoctorCommand), |project_id| {
1018 Ok(Command::Doctor { project_id, json })
1019 })
1020}
1021
1022fn parse_version(args: &[String]) -> Result<Command, CliError> {
1024 let flags = parse_flags(args, FlagScope::Version)?;
1025 Ok(Command::Version {
1026 json: flags.is_json(),
1027 })
1028}
1029
1030fn take_required_arg<'a>(
1032 args: &'a [String],
1033 argument: &'static str,
1034 next: &'static str,
1035) -> Result<(&'a str, &'a [String]), CliError> {
1036 let Some((value, rest)) = args.split_first() else {
1037 return Err(CliError::MissingArgument { argument, next });
1038 };
1039 if value.starts_with('-') {
1040 return Err(CliError::MissingArgument { argument, next });
1041 }
1042 Ok((value.as_str(), rest))
1043}
1044
1045fn move_leading_json_to_tail(args: &[String]) -> Vec<String> {
1047 if args.first().is_some_and(|arg| arg == "--json") {
1048 let mut normalized = Vec::with_capacity(args.len());
1049 normalized.extend(args[1..].iter().cloned());
1050 normalized.push(String::from("--json"));
1051 normalized
1052 } else {
1053 args.to_vec()
1054 }
1055}
1056
1057fn has_position_candidate(args: &[String]) -> bool {
1059 move_leading_json_to_tail(args)
1060 .first()
1061 .is_some_and(|arg| !arg.starts_with('-'))
1062}
1063
1064fn has_trace_id_candidate(args: &[String]) -> bool {
1066 move_leading_json_to_tail(args)
1067 .first()
1068 .is_some_and(|arg| is_trace_id(arg))
1069}
1070
1071fn take_required_position(
1073 args: &[String],
1074 argument: &'static str,
1075 next: &'static str,
1076) -> Result<(String, Vec<String>), CliError> {
1077 let normalized = move_leading_json_to_tail(args);
1078 let (value, rest) = take_required_arg(normalized.as_slice(), argument, next)?;
1079 Ok((value.to_owned(), rest.to_vec()))
1080}
1081
1082fn parse_read(args: &[String]) -> Result<Command, CliError> {
1084 let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
1085 let resource = normalize_read_resource(resource.as_str());
1086 if is_recency_read_verb(resource) {
1087 return parse_read_verb(resource, rest.as_slice());
1088 }
1089 if is_known_issue_status(resource) && has_status_first_issue_resource_candidate(rest.as_slice())
1090 {
1091 return parse_status_first_issue_read(resource, rest.as_slice());
1092 }
1093 parse_read_resource(resource, rest.as_slice())
1094}
1095
1096fn normalize_read_resource(resource: &str) -> &str {
1098 match resource {
1099 "log" => "logs",
1100 "release" => "releases",
1101 _ => resource,
1102 }
1103}
1104
1105fn parse_read_verb(verb: &str, args: &[String]) -> Result<Command, CliError> {
1107 let rewritten_args = recency_count_shortcut_args(verb, args);
1108 let args = rewritten_args.as_deref().unwrap_or(args);
1109 let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
1110 if is_known_issue_status(resource.as_str())
1111 && has_status_first_issue_resource_candidate(rest.as_slice())
1112 {
1113 return parse_status_first_issue_read(resource.as_str(), rest.as_slice());
1114 }
1115 let resource = normalize_read_verb_resource(verb, resource.as_str());
1116 parse_read_resource(resource, rest.as_slice())
1117}
1118
1119fn recency_count_shortcut_args(verb: &str, args: &[String]) -> Option<Vec<String>> {
1121 if !is_recency_read_verb(verb) {
1122 return None;
1123 }
1124 let normalized = move_leading_json_to_tail(args);
1125 let (count, tail) = normalized.split_first().filter(|(count, tail)| {
1126 !tail.is_empty() && count.chars().all(|char| char.is_ascii_digit())
1127 })?;
1128 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1129 rewritten.push(tail[0].clone());
1130 let rest = &tail[1..];
1131 if let Some(separator_index) = rest.iter().position(|arg| arg == "--") {
1132 rewritten.extend(rest[..separator_index].iter().cloned());
1133 rewritten.push(String::from("--limit"));
1134 rewritten.push(count.clone());
1135 rewritten.extend(rest[separator_index..].iter().cloned());
1136 return Some(rewritten);
1137 }
1138 rewritten.extend(rest.iter().cloned());
1139 rewritten.push(String::from("--limit"));
1140 rewritten.push(count.clone());
1141 Some(rewritten)
1142}
1143
1144fn is_read_verb(value: &str) -> bool {
1146 matches!(value, "show" | "list" | "get") || is_recency_read_verb(value)
1147}
1148
1149fn is_recency_read_verb(value: &str) -> bool {
1151 matches!(value, "latest" | "recent" | "last" | "newest")
1152}
1153
1154fn normalize_read_verb_resource<'a>(verb: &str, resource: &'a str) -> &'a str {
1156 match (verb, resource) {
1157 ("list" | "show" | "get", "log") => "logs",
1158 (alias, "log") if is_recency_read_verb(alias) => "logs",
1159 (alias, "issue") if is_recency_read_verb(alias) => "issues",
1160 ("list", "issue") => "issues",
1161 ("list" | "show" | "get", "release") => "releases",
1162 (alias, "release") if is_recency_read_verb(alias) => "releases",
1163 _ => resource,
1164 }
1165}
1166
1167fn is_log_search_shortcut(command: &str) -> bool {
1169 matches!(command, "search" | "find" | "grep")
1170}
1171
1172fn is_log_search_separator_literal(command: &str, args: &[String]) -> bool {
1174 literal_log_search_separator_index(command, args).is_some()
1175}
1176
1177fn log_search_shortcut_label(command: &str) -> &'static str {
1179 match command {
1180 "find" => "find",
1181 "grep" => "grep",
1182 _ => "search",
1183 }
1184}
1185
1186fn parse_search_shortcut(label: &'static str, args: &[String]) -> Result<Command, CliError> {
1188 let (query, tail) = take_search_query(args, label)?;
1189 let mut rest = Vec::with_capacity(tail.len() + 2);
1190 if query.starts_with('-') {
1191 rest.push(format!("--search={query}"));
1192 } else {
1193 rest.push(String::from("--search"));
1194 rest.push(query);
1195 }
1196 rest.extend(tail);
1197 parse_read_resource("logs", rest.as_slice())
1198}
1199
1200fn take_search_query(
1202 args: &[String],
1203 argument: &'static str,
1204) -> Result<(String, Vec<String>), CliError> {
1205 let normalized = move_leading_json_to_tail(args);
1206 if normalized.first().is_some_and(|arg| arg == "--") {
1207 return take_separator_search_query(normalized.as_slice(), argument);
1208 }
1209 let query_word_count = normalized
1210 .iter()
1211 .take_while(|arg| !arg.starts_with('-'))
1212 .count();
1213 if query_word_count == 0 {
1214 return Err(CliError::MissingArgument {
1215 argument,
1216 next: SEARCH_NEXT_STEP,
1217 });
1218 }
1219 let query = normalized[..query_word_count].join(" ");
1220 let tail = normalized[query_word_count..].to_vec();
1221 Ok((query, tail))
1222}
1223
1224fn take_separator_search_query(
1226 args: &[String],
1227 argument: &'static str,
1228) -> Result<(String, Vec<String>), CliError> {
1229 let words = &args[1..];
1230 if words.is_empty() {
1231 return Err(CliError::MissingArgument {
1232 argument,
1233 next: SEARCH_NEXT_STEP,
1234 });
1235 }
1236 let has_trailing_json_mode = words.len() > 1 && words.last().is_some_and(|arg| arg == "--json");
1237 let query_end = if has_trailing_json_mode {
1238 words.len() - 1
1239 } else {
1240 words.len()
1241 };
1242 let query = words[..query_end].join(" ");
1243 let tail = if has_trailing_json_mode {
1244 vec![String::from("--json")]
1245 } else {
1246 Vec::new()
1247 };
1248 Ok((query, tail))
1249}
1250
1251fn parse_read_resource(resource: &str, rest: &[String]) -> Result<Command, CliError> {
1253 let (target, flags) = match resource {
1254 "logs" => parse_log_list_read(rest)?,
1255 alias if is_issue_collection_alias(alias) && has_issue_id_candidate(rest) => {
1256 return parse_issue_detail_or_status(rest);
1257 }
1258 alias if is_issue_collection_alias(alias) => parse_issue_list_read(rest)?,
1259 alias if is_action_collection_alias(alias) => parse_action_list_read(rest)?,
1260 "releases" => parse_list_read(
1261 ReadTarget::Releases,
1262 rest,
1263 "read releases",
1264 READ_RELEASES_NEXT_STEP,
1265 &[
1266 "--name",
1267 "--user",
1268 "--distinct-id",
1269 "--trace",
1270 "--trace-id",
1271 "--level",
1272 "--severity",
1273 "--search",
1274 "--status",
1275 "--min-duration-ms",
1276 ],
1277 )?,
1278 "traces" | "spans" if has_trace_id_candidate(rest) => {
1279 return parse_trace_detail_or_explain(rest);
1280 }
1281 "traces" | "spans" => parse_trace_list_read(rest)?,
1282 "trace" => return parse_trace_detail_or_explain(rest),
1283 "span" if has_position_candidate(rest) => {
1284 return parse_trace_detail_or_explain(rest);
1285 }
1286 "issue" if has_issue_status_candidate(rest) => parse_issue_list_read(rest)?,
1287 "issue" => return parse_issue_detail_or_status(rest),
1288 other => return Err(unknown_read_resource(other)),
1289 };
1290 let json = flags.is_json();
1291 let options = flags.into_read_options();
1292 validate_read_filters(&target, &options)?;
1293
1294 Ok(Command::Read {
1295 target,
1296 options: Box::new(options),
1297 json,
1298 })
1299}
1300
1301fn is_issue_collection_alias(value: &str) -> bool {
1303 matches!(
1304 value,
1305 "issues" | "errors" | "error" | "exceptions" | "exception"
1306 )
1307}
1308
1309fn is_status_first_issue_collection_alias(value: &str) -> bool {
1311 value == "issue" || is_issue_collection_alias(value)
1312}
1313
1314fn has_status_first_issue_resource_candidate(args: &[String]) -> bool {
1316 move_leading_json_to_tail(args)
1317 .first()
1318 .is_some_and(|arg| is_status_first_issue_collection_alias(arg))
1319}
1320
1321fn has_issue_status_candidate(args: &[String]) -> bool {
1323 move_leading_json_to_tail(args)
1324 .first()
1325 .is_some_and(|arg| is_known_issue_status(arg))
1326}
1327
1328fn is_action_collection_alias(value: &str) -> bool {
1330 matches!(value, "actions" | "events" | "event" | "action")
1331}
1332
1333fn parse_log_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1335 let args = log_shortcut_args(rest);
1336 parse_list_read(
1337 ReadTarget::Logs,
1338 args.as_slice(),
1339 "read logs",
1340 READ_LOGS_NEXT_STEP,
1341 &[
1342 "--name",
1343 "--user",
1344 "--distinct-id",
1345 "--status",
1346 "--min-duration-ms",
1347 ],
1348 )
1349}
1350
1351fn parse_issue_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1353 let args = issue_status_shortcut_args(rest);
1354 parse_list_read(
1355 ReadTarget::Issues,
1356 args.as_slice(),
1357 "read issues",
1358 READ_ISSUES_NEXT_STEP,
1359 &[
1360 "--name",
1361 "--user",
1362 "--distinct-id",
1363 "--trace",
1364 "--trace-id",
1365 "--level",
1366 "--severity",
1367 "--search",
1368 "--min-duration-ms",
1369 ],
1370 )
1371}
1372
1373fn parse_status_first_issue_read(status: &str, args: &[String]) -> Result<Command, CliError> {
1375 let canonical_status = normalize_status(status)?;
1376 let (resource, rest) = take_required_position(args, "resource", READ_ISSUES_NEXT_STEP)?;
1377 let resource = resource.as_str();
1378 if !is_status_first_issue_collection_alias(resource) {
1379 return Err(unknown_read_resource(resource));
1380 }
1381 let resource = if resource == "issue" {
1382 "issues"
1383 } else {
1384 resource
1385 };
1386 let mut rewritten = Vec::with_capacity(rest.len() + 2);
1387 rewritten.push(String::from("--status"));
1388 rewritten.push(canonical_status);
1389 rewritten.extend(rest);
1390 parse_read_resource(resource, rewritten.as_slice())
1391}
1392
1393fn issue_status_shortcut_args(args: &[String]) -> Vec<String> {
1395 let normalized = move_leading_json_to_tail(args);
1396 let Some((status, tail)) = normalized
1397 .split_first()
1398 .and_then(|(status, tail)| normalize_status(status).ok().map(|value| (value, tail)))
1399 else {
1400 return args.to_vec();
1401 };
1402 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1403 rewritten.push(String::from("--status"));
1404 rewritten.push(status);
1405 rewritten.extend(tail.iter().cloned());
1406 rewritten
1407}
1408
1409fn parse_action_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1411 let args = action_name_shortcut_args(rest);
1412 parse_list_read(
1413 ReadTarget::Actions,
1414 args.as_slice(),
1415 "read actions",
1416 READ_ACTIONS_NEXT_STEP,
1417 ACTION_LIST_UNSUPPORTED_FLAGS,
1418 )
1419}
1420
1421fn action_name_shortcut_args(args: &[String]) -> Vec<String> {
1423 let normalized = move_leading_json_to_tail(args);
1424 let Some((name, tail)) = normalized
1425 .split_first()
1426 .filter(|(name, _)| !name.starts_with('-') && !is_read_filter_word(name))
1427 else {
1428 return args.to_vec();
1429 };
1430 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1431 rewritten.push(String::from("--name"));
1432 rewritten.push(name.clone());
1433 rewritten.extend(tail.iter().cloned());
1434 rewritten
1435}
1436
1437fn has_issue_id_candidate(args: &[String]) -> bool {
1439 move_leading_json_to_tail(args)
1440 .first()
1441 .is_some_and(|arg| is_issue_id(arg))
1442}
1443
1444fn parse_issue_detail_or_status(args: &[String]) -> Result<Command, CliError> {
1446 let (id, tail) = take_required_position(args, "issue_id", "provide an issue id")?;
1447 if has_issue_status_action(tail.as_slice()) {
1448 return parse_issue_first_status_shortcut(id, tail.as_slice());
1449 }
1450 if let Some(command) =
1451 parse_detail_explain_suffix(ExplainTarget::Issue(id.clone()), tail.as_slice())?
1452 {
1453 return Ok(command);
1454 }
1455 let target = ReadTarget::Issue(id);
1456 let flags = parse_detail_read_flags(
1457 tail.as_slice(),
1458 "read issue",
1459 READ_ISSUE_NEXT_STEP,
1460 ISSUE_DETAIL_UNSUPPORTED_FLAGS,
1461 )?;
1462 let json = flags.is_json();
1463 let options = flags.into_read_options();
1464 validate_read_filters(&target, &options)?;
1465
1466 Ok(Command::Read {
1467 target,
1468 options: Box::new(options),
1469 json,
1470 })
1471}
1472
1473fn parse_list_read(
1475 target: ReadTarget,
1476 args: &[String],
1477 command: &'static str,
1478 next: &'static str,
1479 unsupported_flags: &[&str],
1480) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1481 reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
1482 Ok((target, parse_flags(args, FlagScope::Read)?))
1483}
1484
1485fn reject_unsupported_read_flags(
1487 args: &[String],
1488 command: &'static str,
1489 next: &'static str,
1490 unsupported_flags: &[&str],
1491) -> Result<(), CliError> {
1492 let mut index = 0;
1493 let mut seen = Vec::new();
1494 while let Some(arg) = args.get(index) {
1495 let (flag, inline_value) = arg
1496 .split_once('=')
1497 .map_or((arg.as_str(), None), |(name, value)| (name, Some(value)));
1498 if !is_read_value_flag(flag) {
1499 if flag == "--json" && inline_value.is_none() {
1500 if seen.contains(&"--json") {
1501 return Ok(());
1502 }
1503 seen.push("--json");
1504 index += 1;
1505 continue;
1506 }
1507 if inline_value.is_some() && is_simple_flag(flag) {
1508 return Err(CliError::UnsupportedFlag {
1509 flag: arg.to_owned(),
1510 command,
1511 next,
1512 });
1513 }
1514 if arg.starts_with('-') {
1515 return Err(unknown_flag(arg, next));
1516 }
1517 return Ok(());
1518 }
1519 if unsupported_flags.contains(&flag) {
1520 return Err(CliError::UnsupportedFlag {
1521 flag: user_facing_read_flag(flag).to_owned(),
1522 command,
1523 next,
1524 });
1525 }
1526 if let Some(canonical) = read_value_canonical_flag(flag) {
1527 if seen.contains(&canonical) {
1528 return Ok(());
1529 }
1530 seen.push(canonical);
1531 }
1532 if inline_value.is_some_and(str::is_empty) {
1533 return Ok(());
1534 }
1535 if inline_value.is_some_and(|value| has_invalid_supported_read_value(flag, value)) {
1536 return Ok(());
1537 }
1538 if inline_value.is_none() {
1539 let Some(value) = args.get(index + 1) else {
1540 return Ok(());
1541 };
1542 if value.starts_with('-') {
1543 return Ok(());
1544 }
1545 if has_invalid_supported_read_value(flag, value) {
1546 return Ok(());
1547 }
1548 index += 1;
1549 }
1550 index += 1;
1551 }
1552 Ok(())
1553}
1554
1555fn read_value_canonical_flag(flag: &str) -> Option<&'static str> {
1557 let canonical = match flag {
1558 "--name" => "--name",
1559 "--service" | "--service-name" => "--service",
1560 "--since" => "--since",
1561 "--user" | "--distinct-id" => "--user",
1562 "--trace" | "--trace-id" => "--trace",
1563 "--level" | "--severity" => "--severity",
1564 "--search" => "--search",
1565 "--project" | "--project-id" => "--project",
1566 "--release" => "--release",
1567 "--environment" | "--env" => "--environment",
1568 "--status" => "--status",
1569 "--limit" => "--limit",
1570 "--min-duration-ms" => "--min-duration-ms",
1571 "--pagination" => "--pagination",
1572 "--cursor-time" => "--cursor-time",
1573 "--cursor-id" => "--cursor-id",
1574 _ => return None,
1575 };
1576 Some(canonical)
1577}
1578
1579fn user_facing_read_flag(flag: &str) -> &str {
1581 match flag {
1582 "--level" => "--severity",
1583 "--service-name" => "--service",
1584 other => other,
1585 }
1586}
1587
1588fn has_invalid_supported_read_value(flag: &str, value: &str) -> bool {
1590 match flag {
1591 "--level" | "--severity" => !is_known_log_level(value),
1592 "--status" => !is_known_issue_status(value),
1593 "--limit" => value.parse::<u32>().map_or(true, |limit| limit == 0),
1594 "--min-duration-ms" => validate_min_duration(value).is_err(),
1595 "--pagination" => value != "cursor",
1596 _ => false,
1597 }
1598}
1599
1600fn is_known_log_level(value: &str) -> bool {
1602 normalize_log_level(value).is_ok()
1603}
1604
1605fn is_ambiguous_log_search_word(value: &str) -> bool {
1607 is_read_filter_word(value) || value.contains('@') || is_trace_id(value)
1608}
1609
1610fn is_known_issue_status(value: &str) -> bool {
1612 normalize_status(value).is_ok()
1613}
1614
1615fn is_read_value_flag(flag: &str) -> bool {
1617 matches!(
1618 flag,
1619 "--name"
1620 | "--service"
1621 | "--service-name"
1622 | "--since"
1623 | "--user"
1624 | "--distinct-id"
1625 | "--trace"
1626 | "--trace-id"
1627 | "--level"
1628 | "--severity"
1629 | "--search"
1630 | "--project"
1631 | "--project-id"
1632 | "--release"
1633 | "--environment"
1634 | "--env"
1635 | "--status"
1636 | "--limit"
1637 | "--min-duration-ms"
1638 | "--pagination"
1639 | "--cursor-time"
1640 | "--cursor-id"
1641 )
1642}
1643
1644fn parse_detail_explain_suffix(
1646 target: ExplainTarget,
1647 args: &[String],
1648) -> Result<Option<Command>, CliError> {
1649 let normalized = move_leading_json_to_tail(args);
1650 if normalized.first().is_none_or(|arg| arg != "explain") {
1651 return Ok(None);
1652 }
1653 Ok(Some(Command::Explain {
1654 target,
1655 json: parse_flags(&normalized[1..], FlagScope::Explain)?.is_json(),
1656 }))
1657}
1658
1659fn parse_detail_read_flags(
1661 args: &[String],
1662 command: &'static str,
1663 next: &'static str,
1664 unsupported_flags: &[&str],
1665) -> Result<crate::flags::Flags, CliError> {
1666 reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
1667 parse_flags(args, FlagScope::Read)
1668}
1669
1670fn parse_pasted_detail_id(id: &str, args: &[String]) -> Result<Command, CliError> {
1672 if is_issue_id(id) && has_issue_status_action(args) {
1673 return parse_issue_first_status_shortcut(id.to_owned(), args);
1674 }
1675 let explain_args = move_leading_json_to_tail(args);
1676 if explain_args.first().is_some_and(|arg| arg == "explain") {
1677 let target = infer_explain_target(id).ok_or_else(|| unknown_command(id))?;
1678 return Ok(Command::Explain {
1679 target,
1680 json: parse_flags(&explain_args[1..], FlagScope::Explain)?.is_json(),
1681 });
1682 }
1683 let (target, flags) = if is_trace_id(id) {
1684 (
1685 ReadTarget::Trace(id.to_owned()),
1686 parse_detail_read_flags(
1687 args,
1688 "read trace",
1689 READ_TRACE_NEXT_STEP,
1690 TRACE_DETAIL_UNSUPPORTED_FLAGS,
1691 )?,
1692 )
1693 } else if is_issue_id(id) {
1694 (
1695 ReadTarget::Issue(id.to_owned()),
1696 parse_detail_read_flags(
1697 args,
1698 "read issue",
1699 READ_ISSUE_NEXT_STEP,
1700 ISSUE_DETAIL_UNSUPPORTED_FLAGS,
1701 )?,
1702 )
1703 } else {
1704 return Err(unknown_command(id));
1705 };
1706 let json = flags.is_json();
1707 let options = flags.into_read_options();
1708 validate_read_filters(&target, &options)?;
1709
1710 Ok(Command::Read {
1711 target,
1712 options: Box::new(options),
1713 json,
1714 })
1715}
1716
1717fn validate_read_filters(target: &ReadTarget, filters: &ReadOptions) -> Result<(), CliError> {
1719 let unsupported = match target {
1720 ReadTarget::Logs => filters
1721 .first_log_unsupported_flag()
1722 .map(|flag| (flag, "read logs", READ_LOGS_NEXT_STEP)),
1723 ReadTarget::Issues => filters
1724 .first_issue_list_unsupported_flag()
1725 .map(|flag| (flag, "read issues", READ_ISSUES_NEXT_STEP)),
1726 ReadTarget::Actions => filters
1727 .first_action_unsupported_flag()
1728 .map(|flag| (flag, "read actions", READ_ACTIONS_NEXT_STEP)),
1729 ReadTarget::Releases => filters
1730 .first_release_unsupported_flag()
1731 .map(|flag| (flag, "read releases", READ_RELEASES_NEXT_STEP)),
1732 ReadTarget::Traces => filters
1733 .first_trace_list_unsupported_flag()
1734 .map(|flag| (flag, "read traces", READ_TRACES_NEXT_STEP)),
1735 ReadTarget::Trace(_) => filters
1736 .first_trace_detail_unsupported_flag()
1737 .map(|flag| (flag, "read trace", READ_TRACE_NEXT_STEP)),
1738 ReadTarget::Issue(_) => filters
1739 .first_issue_detail_unsupported_flag()
1740 .map(|flag| (flag, "read issue", READ_ISSUE_NEXT_STEP)),
1741 };
1742
1743 if let Some((flag, command, next)) = unsupported {
1744 return Err(CliError::UnsupportedFlag {
1745 flag: flag.to_owned(),
1746 command,
1747 next,
1748 });
1749 }
1750 match target {
1751 ReadTarget::Logs => validate_read_cursor(filters, CliError::InvalidLogCursor)?,
1752 ReadTarget::Actions => validate_read_cursor(filters, CliError::InvalidActionCursor)?,
1753 ReadTarget::Issues => validate_read_cursor(filters, CliError::InvalidIssueCursor)?,
1754 ReadTarget::Releases | ReadTarget::Traces | ReadTarget::Trace(_) | ReadTarget::Issue(_) => {
1755 }
1756 }
1757 Ok(())
1758}
1759
1760fn validate_read_cursor(
1762 filters: &ReadOptions,
1763 invalid_cursor: fn(String) -> CliError,
1764) -> Result<(), CliError> {
1765 match (
1766 filters.pagination.as_deref(),
1767 filters.cursor_time.as_ref(),
1768 filters.cursor_id.as_ref(),
1769 ) {
1770 (None | Some("cursor"), None, None) | (Some("cursor"), Some(_), Some(_)) => Ok(()),
1771 (None, _, _) => Err(invalid_cursor(String::from(
1772 "cursor fields require --pagination cursor",
1773 ))),
1774 (Some("cursor"), _, _) => Err(invalid_cursor(String::from(
1775 "--cursor-time and --cursor-id must be used together",
1776 ))),
1777 (Some(_), _, _) => Err(CliError::UnknownPagination),
1778 }
1779}
1780
1781fn parse_explain(args: &[String]) -> Result<Command, CliError> {
1783 let (resource, rest) = take_required_position(args, "resource", EXPLAIN_RESOURCE_NEXT_STEP)?;
1784 let (target, tail) = match resource.as_str() {
1785 "issue" => {
1786 let (id, tail) =
1787 take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1788 (ExplainTarget::Issue(id), tail)
1789 }
1790 "trace" => {
1791 let (id, tail) =
1792 take_required_position(rest.as_slice(), "trace_id", "provide a trace id")?;
1793 (ExplainTarget::Trace(id), tail)
1794 }
1795 other => {
1796 if let Some(target) = infer_explain_target(other) {
1797 (target, rest)
1798 } else {
1799 return Err(unknown_resource(other, EXPLAIN_RESOURCE_NEXT_STEP));
1800 }
1801 }
1802 };
1803 let flags = parse_flags(tail.as_slice(), FlagScope::Explain)?;
1804 Ok(Command::Explain {
1805 target,
1806 json: flags.is_json(),
1807 })
1808}
1809
1810fn parse_set(args: &[String]) -> Result<Command, CliError> {
1812 let (resource, rest) = take_required_position(args, "resource", SET_RESOURCE_NEXT_STEP)?;
1813 if resource != "issue" {
1814 return Err(unknown_resource(resource.as_str(), SET_RESOURCE_NEXT_STEP));
1815 }
1816 let (id, rest) = take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1817 let (status, tail) =
1818 take_required_position(rest.as_slice(), "status", ISSUE_STATUS_ARGUMENT_NEXT_STEP)?;
1819 let status = normalize_status(status.as_str())?;
1820 let flags = parse_flags(tail.as_slice(), FlagScope::Set)?;
1821
1822 Ok(Command::Set {
1823 target: SetTarget::IssueStatus { id, status },
1824 json: flags.is_json(),
1825 })
1826}