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