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 }),
263 json: parsed.json,
264 })
265}
266
267fn parse_native_debug_lookup(args: &[String]) -> Result<Command, CliError> {
269 let parsed = parse_native_debug_scope(args, true)?;
270 let image_uuid = parsed
271 .image_uuid
272 .filter(|value| is_canonical_lower_uuid(value))
273 .ok_or(CliError::InvalidNativeDebugIdentity)?;
274 let architecture = parsed
275 .architecture
276 .filter(|value| matches!(value.as_str(), "arm64" | "arm64e" | "x86_64"))
277 .ok_or(CliError::InvalidNativeDebugIdentity)?;
278 Ok(Command::NativeDebugArtifacts {
279 target: crate::NativeDebugArtifactsTarget::Lookup(crate::NativeDebugLookupOptions {
280 project_id: parsed.project_id,
281 release: parsed.release,
282 environment: parsed.environment,
283 service: parsed.service,
284 image_uuid,
285 architecture,
286 }),
287 json: parsed.json,
288 })
289}
290
291#[derive(Default)]
293struct NativeDebugScope {
294 project_id: String,
296 release: String,
298 environment: String,
300 service: String,
302 image_uuid: Option<String>,
304 architecture: Option<String>,
306 json: bool,
308}
309
310fn parse_native_debug_scope(args: &[String], lookup: bool) -> Result<NativeDebugScope, CliError> {
312 let mut project_id = None;
313 let mut release = None;
314 let mut environment = None;
315 let mut service = None;
316 let mut image_uuid = None;
317 let mut architecture = None;
318 let mut json = false;
319 let mut index = 0;
320 while let Some(flag) = args.get(index) {
321 if flag == "--json" {
322 if json {
323 return Err(CliError::InvalidNativeDebugCommand);
324 }
325 json = true;
326 index += 1;
327 continue;
328 }
329 let destination = match flag.as_str() {
330 "--project" => &mut project_id,
331 "--release" => &mut release,
332 "--environment" => &mut environment,
333 "--service" => &mut service,
334 "--image-uuid" if lookup => &mut image_uuid,
335 "--architecture" if lookup => &mut architecture,
336 _ => return Err(CliError::InvalidNativeDebugCommand),
337 };
338 if destination.is_some() {
339 return Err(CliError::InvalidNativeDebugCommand);
340 }
341 let value = args
342 .get(index + 1)
343 .ok_or(CliError::InvalidNativeDebugCommand)?;
344 *destination = Some(value.clone());
345 index += 2;
346 }
347
348 let project_id = project_id
349 .filter(|value| is_canonical_lower_uuid(value))
350 .ok_or(CliError::InvalidNativeDebugCommand)?;
351 let release = normalize_native_scope(release).ok_or(CliError::InvalidNativeDebugCommand)?;
352 let environment =
353 normalize_native_scope(environment).ok_or(CliError::InvalidNativeDebugCommand)?;
354 let service = normalize_native_scope(service).ok_or(CliError::InvalidNativeDebugCommand)?;
355 if lookup != (image_uuid.is_some() && architecture.is_some()) {
356 return Err(CliError::InvalidNativeDebugCommand);
357 }
358 Ok(NativeDebugScope {
359 project_id,
360 release,
361 environment,
362 service,
363 image_uuid,
364 architecture,
365 json,
366 })
367}
368
369fn normalize_native_scope(value: Option<String>) -> Option<String> {
371 let value = value?;
372 let trimmed = value.trim();
373 (!trimmed.is_empty() && trimmed.len() <= 256 && !trimmed.chars().any(char::is_control))
374 .then(|| trimmed.to_owned())
375}
376
377fn is_canonical_lower_uuid(value: &str) -> bool {
379 value.len() == 36
380 && value.bytes().enumerate().all(|(index, byte)| {
381 matches!(index, 8 | 13 | 18 | 23) && byte == b'-'
382 || !matches!(index, 8 | 13 | 18 | 23)
383 && (byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
384 })
385}
386
387fn parse_investigate(args: &[String]) -> Result<Command, CliError> {
389 let normalized = move_leading_json_to_tail(args);
390 match normalized.as_slice() {
391 [resource, issue_id]
392 if resource == "issue" && is_safe_investigation_issue_id(issue_id.as_str()) =>
393 {
394 Ok(Command::InvestigateIssue {
395 issue_id: issue_id.clone(),
396 json: false,
397 })
398 }
399 [resource, issue_id, json]
400 if resource == "issue"
401 && is_safe_investigation_issue_id(issue_id.as_str())
402 && json == "--json" =>
403 {
404 Ok(Command::InvestigateIssue {
405 issue_id: issue_id.clone(),
406 json: true,
407 })
408 }
409 _ => Err(CliError::InvalidInvestigationCommand),
410 }
411}
412
413fn is_safe_investigation_issue_id(value: &str) -> bool {
415 value.len() == 36
416 && value.bytes().enumerate().all(|(index, byte)| {
417 matches!(index, 8 | 13 | 18 | 23) && byte == b'-'
418 || !matches!(index, 8 | 13 | 18 | 23)
419 && (byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
420 })
421}
422
423fn parse_discovery_help(topic: HelpTopic, args: &[String]) -> Result<Command, CliError> {
425 validate_help_flags(args)?;
426 Ok(Command::Help {
427 topic,
428 json: args.iter().any(|arg| arg == "--json"),
429 })
430}
431
432fn parse_global_json(values: &[String], tail: &[String]) -> Result<Command, CliError> {
434 if global_json_tail_has_duplicate(tail) {
435 return Err(CliError::DuplicateFlag {
436 flag: "--json",
437 next: "use --json once",
438 });
439 }
440 if tail.is_empty() {
441 return Ok(Command::Help {
442 topic: HelpTopic::Root,
443 json: true,
444 });
445 }
446
447 let mut normalized = Vec::with_capacity(values.len());
448 if let Some(program) = values.first() {
449 normalized.push(program.clone());
450 }
451 normalized.extend(tail.iter().cloned());
452 normalized.push(String::from("--json"));
453 parse_values(normalized.as_slice())
454}
455
456fn global_json_tail_has_duplicate(tail: &[String]) -> bool {
458 if !tail.iter().any(|arg| arg == "--json") {
459 return false;
460 }
461 let Some((command, rest)) = tail.split_first() else {
462 return false;
463 };
464 let Some(separator_index) = literal_log_search_separator_index(command, rest) else {
465 return true;
466 };
467 rest[..separator_index].iter().any(|arg| arg == "--json")
468}
469fn unknown_resource(resource: &str, next: &'static str) -> CliError {
471 CliError::UnknownResource {
472 resource: resource.to_owned(),
473 next,
474 }
475}
476
477fn unknown_flag(flag: &str, next: &'static str) -> CliError {
479 CliError::UnknownFlag {
480 flag: flag.to_owned(),
481 next,
482 }
483}
484
485fn unknown_read_resource(resource: &str) -> CliError {
487 unknown_resource(resource, read_resource_next_step(resource))
488}
489
490fn read_resource_next_step(resource: &str) -> &'static str {
492 match resource {
493 "trace" | "traces" | "span" | "spans" => READ_TRACE_ALIAS_NEXT_STEP,
494 _ => READ_RESOURCE_NEXT_STEP,
495 }
496}
497
498fn unknown_command(command: &str) -> CliError {
500 CliError::UnknownCommandName {
501 command: command.to_owned(),
502 next: unknown_command_next_step(command),
503 }
504}
505
506fn unknown_command_next_step(command: &str) -> &'static str {
508 match command {
509 "logg" | "lgs" => "did you mean logbrew logs?",
510 "action" | "event" | "events" => "did you mean logbrew actions?",
511 "releaze" | "rels" => "did you mean logbrew releases?",
512 "statuz" | "stats" => "did you mean logbrew status?",
513 "error" | "errors" | "exception" | "exceptions" => "did you mean logbrew issues?",
514 "trace" | "traces" | "span" | "spans" => TRACE_COMMAND_NEXT_STEP,
515 "env" | "environment" | "environments" => {
516 "use --environment <environment> with logs, issues, actions, releases, or traces"
517 }
518 alias if auth_namespace::is_help_alias(alias) => "run logbrew help auth",
519 _ => HELP_NEXT_STEP,
520 }
521}
522
523fn is_status_help_alias(value: &str) -> bool {
525 matches!(value, "status" | "health" | "ping" | "doctor")
526}
527
528fn is_examples_help_alias(value: &str) -> bool {
530 matches!(
531 value,
532 "example" | "examples" | "sample" | "samples" | "recipe" | "recipes"
533 )
534}
535
536fn is_setup_alias(value: &str) -> bool {
538 matches!(value, "setup" | "init" | "install" | "configure" | "sdk")
539}
540
541fn is_project_help_alias(value: &str) -> bool {
543 matches!(value, "project" | "projects")
544}
545
546fn is_watch_command_alias(value: &str) -> bool {
548 matches!(value, "watch" | "tail" | "follow" | "stream")
549}
550
551fn is_version_flag(value: &str) -> bool {
553 matches!(value, "--version" | "-V")
554}
555
556fn parse_login(args: &[String]) -> Result<Command, CliError> {
558 let flags = parse_flags(args, FlagScope::Login)?;
559 let json = flags.is_json();
560 Ok(Command::Login {
561 open_browser: flags.should_open_browser() && !json,
562 json,
563 })
564}
565
566fn parse_logout(args: &[String]) -> Result<Command, CliError> {
568 let flags = parse_flags(args, FlagScope::Logout)?;
569 Ok(Command::Logout {
570 json: flags.is_json(),
571 })
572}
573
574fn parse_setup(args: &[String]) -> Result<Command, CliError> {
576 if args.iter().any(|arg| arg == "--create-project") {
577 return parse_setup_create_project(args);
578 }
579 let flags = parse_flags(args, FlagScope::Setup)?;
580 Ok(Command::Setup {
581 auto: flags.is_auto(),
582 yes: flags.skip_prompts(),
583 json: flags.is_json(),
584 })
585}
586
587fn parse_setup_create_project(args: &[String]) -> Result<Command, CliError> {
589 let mut seen_create_project = false;
590 let mut seen_json = false;
591
592 for arg in args {
593 match arg.as_str() {
594 "--create-project" => {
595 if std::mem::replace(&mut seen_create_project, true) {
596 return Err(CliError::DuplicateFlag {
597 flag: "--create-project",
598 next: "use --create-project once",
599 });
600 }
601 }
602 "--json" => {
603 if std::mem::replace(&mut seen_json, true) {
604 return Err(CliError::DuplicateFlag {
605 flag: "--json",
606 next: "use --json once",
607 });
608 }
609 }
610 "--help" | "-h" => {}
611 flag if flag.starts_with('-') => {
612 return Err(unknown_flag(flag, PROJECTS_NEXT_STEP));
613 }
614 argument => {
615 return Err(CliError::UnexpectedArgument {
616 argument: argument.to_owned(),
617 command: "setup",
618 next: PROJECTS_NEXT_STEP,
619 });
620 }
621 }
622 }
623
624 Ok(Command::Help {
625 topic: HelpTopic::Projects,
626 json: seen_json,
627 })
628}
629
630fn parse_project(args: &[String]) -> Result<Command, CliError> {
632 let normalized = move_leading_json_to_tail(args);
633 if let Some((subcommand, tail)) = normalized.split_first()
634 && subcommand == "create"
635 {
636 return parse_project_create(tail);
637 }
638 if let Some((subcommand, tail)) = normalized.split_first()
639 && subcommand == "setup"
640 && has_position_candidate(tail)
641 {
642 return parse_project_setup_seen(tail);
643 }
644 parse_discovery_help(HelpTopic::Projects, args)
645}
646
647fn parse_project_create(args: &[String]) -> Result<Command, CliError> {
649 let Some((name, tail)) = args.split_first() else {
650 return Err(CliError::InvalidProjectCreateCommand);
651 };
652 if name.starts_with('-') {
653 return Err(CliError::InvalidProjectCreateCommand);
654 }
655 let name = bounded_project_create_value(name, 120, false)
656 .ok_or(CliError::InvalidProjectCreateCommand)?;
657 if name.starts_with('-') {
658 return Err(CliError::InvalidProjectCreateCommand);
659 }
660 let mut runtime = None;
661 let mut environment = None;
662 let mut ingest_key_file = None;
663 let mut abandon_retry = false;
664 let mut json = false;
665 let mut index = 0;
666
667 while let Some(argument) = tail.get(index) {
668 let (flag, inline_value) = argument
669 .split_once('=')
670 .map_or((argument.as_str(), None), |(flag, value)| {
671 (flag, Some(value))
672 });
673 match flag {
674 "--runtime" if runtime.is_none() => {
675 let value = project_create_flag_value(tail, &mut index, inline_value)?;
676 runtime = optional_project_create_value(value, 64)?;
677 }
678 "--environment" if environment.is_none() => {
679 let value = project_create_flag_value(tail, &mut index, inline_value)?;
680 environment = optional_project_create_value(value, 64)?;
681 }
682 "--ingest-key-file" if ingest_key_file.is_none() => {
683 let value = project_create_flag_value(tail, &mut index, inline_value)?;
684 let trimmed = value.trim();
685 if trimmed.is_empty()
686 || trimmed.len() > 4096
687 || trimmed.chars().any(char::is_control)
688 {
689 return Err(CliError::InvalidProjectCreateCommand);
690 }
691 ingest_key_file = Some(trimmed.to_owned());
692 }
693 "--abandon-retry" if inline_value.is_none() && !abandon_retry => {
694 abandon_retry = true;
695 }
696 "--json" if inline_value.is_none() && !json => json = true,
697 _ => return Err(CliError::InvalidProjectCreateCommand),
698 }
699 index += 1;
700 }
701
702 let ingest_key_file = ingest_key_file.ok_or(CliError::InvalidProjectCreateCommand)?;
703 Ok(Command::ProjectCreate {
704 options: ProjectCreateOptions {
705 name,
706 runtime,
707 environment,
708 ingest_key_file,
709 abandon_retry,
710 },
711 json,
712 })
713}
714
715fn project_create_flag_value<'a>(
717 args: &'a [String],
718 index: &mut usize,
719 inline: Option<&'a str>,
720) -> Result<&'a str, CliError> {
721 if let Some(value) = inline {
722 return Ok(value);
723 }
724 *index += 1;
725 args.get(*index)
726 .map(String::as_str)
727 .filter(|value| !value.starts_with('-'))
728 .ok_or(CliError::InvalidProjectCreateCommand)
729}
730
731fn bounded_project_create_value(value: &str, limit: usize, allow_blank: bool) -> Option<String> {
733 let value = value.trim();
734 let length = value.chars().count();
735 if value.chars().any(char::is_control) || length > limit || (!allow_blank && length == 0) {
736 return None;
737 }
738 (!value.is_empty()).then(|| value.to_owned())
739}
740
741fn optional_project_create_value(value: &str, limit: usize) -> Result<Option<String>, CliError> {
743 let trimmed = value.trim();
744 if trimmed.is_empty() {
745 return Ok(None);
746 }
747 bounded_project_create_value(trimmed, limit, false)
748 .map(Some)
749 .ok_or(CliError::InvalidProjectCreateCommand)
750}
751
752fn parse_project_setup_seen(args: &[String]) -> Result<Command, CliError> {
754 let (project_id, tail) =
755 take_required_position(args, "project_id", PROJECT_SETUP_SEEN_NEXT_STEP)?;
756 let (options, json) = parse_project_setup_seen_flags(tail.as_slice())?;
757 Ok(Command::ProjectSetupSeen {
758 project_id,
759 options,
760 json,
761 })
762}
763
764fn parse_project_setup_seen_flags(
766 args: &[String],
767) -> Result<(ProjectSetupSeenOptions, bool), CliError> {
768 let mut options = ProjectSetupSeenOptions::default();
769 let mut json = false;
770 let mut seen = Vec::new();
771 let mut index = 0;
772
773 while let Some(arg) = args.get(index) {
774 let (flag, inline_value) = split_project_setup_seen_inline_value(arg.as_str());
775 match flag {
776 "--json" if inline_value.is_none() => {
777 mark_project_setup_seen_flag(&mut seen, "--json")?;
778 json = true;
779 }
780 "--runtime" => {
781 mark_project_setup_seen_flag(&mut seen, "--runtime")?;
782 options.runtime = Some(project_setup_seen_flag_value(
783 args,
784 &mut index,
785 "--runtime",
786 inline_value,
787 )?);
788 }
789 "--source" => {
790 mark_project_setup_seen_flag(&mut seen, "--source")?;
791 options.source = Some(validate_project_setup_seen_source(
792 project_setup_seen_flag_value(args, &mut index, "--source", inline_value)?
793 .as_str(),
794 )?);
795 }
796 "--environment" | "--env" => {
797 mark_project_setup_seen_flag(&mut seen, "--environment")?;
798 let visible_flag = if flag == "--env" {
799 "--env"
800 } else {
801 "--environment"
802 };
803 options.environment = Some(project_setup_seen_flag_value(
804 args,
805 &mut index,
806 visible_flag,
807 inline_value,
808 )?);
809 }
810 flag if flag.starts_with('-') => {
811 return Err(unknown_flag(flag, PROJECT_SETUP_SEEN_NEXT_STEP));
812 }
813 argument => {
814 return Err(CliError::UnexpectedArgument {
815 argument: argument.to_owned(),
816 command: "projects setup",
817 next: PROJECT_SETUP_SEEN_NEXT_STEP,
818 });
819 }
820 }
821 index += 1;
822 }
823
824 Ok((options, json))
825}
826
827fn split_project_setup_seen_inline_value(flag: &str) -> (&str, Option<&str>) {
829 flag.split_once('=')
830 .map_or((flag, None), |(name, value)| (name, Some(value)))
831}
832
833fn mark_project_setup_seen_flag(
835 seen: &mut Vec<&'static str>,
836 flag: &'static str,
837) -> Result<(), CliError> {
838 if seen.contains(&flag) {
839 return Err(CliError::DuplicateFlag {
840 flag,
841 next: project_setup_seen_duplicate_next(flag),
842 });
843 }
844 seen.push(flag);
845 Ok(())
846}
847
848fn project_setup_seen_duplicate_next(flag: &'static str) -> &'static str {
850 match flag {
851 "--json" => "use --json once",
852 "--runtime" => "use --runtime once",
853 "--source" => "use --source once",
854 "--environment" => "use --environment once",
855 _ => "use the flag once",
856 }
857}
858
859fn project_setup_seen_flag_value(
861 args: &[String],
862 index: &mut usize,
863 flag: &'static str,
864 inline_value: Option<&str>,
865) -> Result<String, CliError> {
866 if let Some(value) = inline_value {
867 if value.is_empty() {
868 return Err(missing_project_setup_seen_flag_value(flag));
869 }
870 return Ok(value.to_owned());
871 }
872 *index += 1;
873 let Some(value) = args.get(*index) else {
874 return Err(missing_project_setup_seen_flag_value(flag));
875 };
876 if value.starts_with('-') {
877 return Err(missing_project_setup_seen_flag_value(flag));
878 }
879 Ok(value.clone())
880}
881
882fn missing_project_setup_seen_flag_value(flag: &'static str) -> CliError {
884 CliError::MissingFlagValue {
885 flag,
886 next: project_setup_seen_missing_value_next(flag),
887 }
888}
889
890fn project_setup_seen_missing_value_next(flag: &'static str) -> &'static str {
892 match flag {
893 "--runtime" => "provide a value after --runtime",
894 "--source" => PROJECT_SETUP_SOURCE_NEXT_STEP,
895 "--environment" => "provide a value after --environment",
896 "--env" => "provide a value after --env",
897 _ => "provide a value after the flag",
898 }
899}
900
901fn validate_project_setup_seen_source(source: &str) -> Result<String, CliError> {
903 match source {
904 "api" | "cli" | "sdk" => Ok(source.to_owned()),
905 other => Err(CliError::InvalidSetupSource(other.to_owned())),
906 }
907}
908
909fn parse_status(args: &[String]) -> Result<Command, CliError> {
911 let flags = parse_flags(args, FlagScope::Status)?;
912 Ok(Command::Status {
913 json: flags.is_json(),
914 })
915}
916
917fn parse_usage(args: &[String]) -> Result<Command, CliError> {
919 match args {
920 [] => Ok(Command::Usage { json: false }),
921 [flag] if flag == "--json" => Ok(Command::Usage { json: true }),
922 _ => Err(CliError::InvalidUsageCommand),
923 }
924}
925
926fn parse_doctor(args: &[String]) -> Result<Command, CliError> {
928 if args.iter().all(|arg| arg == "--json") {
929 return parse_status(args);
930 }
931
932 let mut project_id = None;
933 let mut json = false;
934 let mut index = 0;
935 while let Some(argument) = args.get(index) {
936 if let Some(value) = argument
937 .strip_prefix("--project=")
938 .or_else(|| argument.strip_prefix("--project-id="))
939 {
940 if project_id.is_some() || !crate::ids::is_uuid(value) {
941 return Err(CliError::InvalidDoctorCommand);
942 }
943 project_id = Some(value.to_owned());
944 index += 1;
945 continue;
946 }
947 match argument.as_str() {
948 "--json" if !json => json = true,
949 "--project" | "--project-id" if project_id.is_none() => {
950 index += 1;
951 let Some(value) = args.get(index) else {
952 return Err(CliError::InvalidDoctorCommand);
953 };
954 if !crate::ids::is_uuid(value) {
955 return Err(CliError::InvalidDoctorCommand);
956 }
957 project_id = Some(value.clone());
958 }
959 _ => return Err(CliError::InvalidDoctorCommand),
960 }
961 index += 1;
962 }
963
964 project_id.map_or(Err(CliError::InvalidDoctorCommand), |project_id| {
965 Ok(Command::Doctor { project_id, json })
966 })
967}
968
969fn parse_version(args: &[String]) -> Result<Command, CliError> {
971 let flags = parse_flags(args, FlagScope::Version)?;
972 Ok(Command::Version {
973 json: flags.is_json(),
974 })
975}
976
977fn take_required_arg<'a>(
979 args: &'a [String],
980 argument: &'static str,
981 next: &'static str,
982) -> Result<(&'a str, &'a [String]), CliError> {
983 let Some((value, rest)) = args.split_first() else {
984 return Err(CliError::MissingArgument { argument, next });
985 };
986 if value.starts_with('-') {
987 return Err(CliError::MissingArgument { argument, next });
988 }
989 Ok((value.as_str(), rest))
990}
991
992fn move_leading_json_to_tail(args: &[String]) -> Vec<String> {
994 if args.first().is_some_and(|arg| arg == "--json") {
995 let mut normalized = Vec::with_capacity(args.len());
996 normalized.extend(args[1..].iter().cloned());
997 normalized.push(String::from("--json"));
998 normalized
999 } else {
1000 args.to_vec()
1001 }
1002}
1003
1004fn has_position_candidate(args: &[String]) -> bool {
1006 move_leading_json_to_tail(args)
1007 .first()
1008 .is_some_and(|arg| !arg.starts_with('-'))
1009}
1010
1011fn has_trace_id_candidate(args: &[String]) -> bool {
1013 move_leading_json_to_tail(args)
1014 .first()
1015 .is_some_and(|arg| is_trace_id(arg))
1016}
1017
1018fn take_required_position(
1020 args: &[String],
1021 argument: &'static str,
1022 next: &'static str,
1023) -> Result<(String, Vec<String>), CliError> {
1024 let normalized = move_leading_json_to_tail(args);
1025 let (value, rest) = take_required_arg(normalized.as_slice(), argument, next)?;
1026 Ok((value.to_owned(), rest.to_vec()))
1027}
1028
1029fn parse_read(args: &[String]) -> Result<Command, CliError> {
1031 let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
1032 let resource = normalize_read_resource(resource.as_str());
1033 if is_recency_read_verb(resource) {
1034 return parse_read_verb(resource, rest.as_slice());
1035 }
1036 if is_known_issue_status(resource) && has_status_first_issue_resource_candidate(rest.as_slice())
1037 {
1038 return parse_status_first_issue_read(resource, rest.as_slice());
1039 }
1040 parse_read_resource(resource, rest.as_slice())
1041}
1042
1043fn normalize_read_resource(resource: &str) -> &str {
1045 match resource {
1046 "log" => "logs",
1047 "release" => "releases",
1048 _ => resource,
1049 }
1050}
1051
1052fn parse_read_verb(verb: &str, args: &[String]) -> Result<Command, CliError> {
1054 let rewritten_args = recency_count_shortcut_args(verb, args);
1055 let args = rewritten_args.as_deref().unwrap_or(args);
1056 let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
1057 if is_known_issue_status(resource.as_str())
1058 && has_status_first_issue_resource_candidate(rest.as_slice())
1059 {
1060 return parse_status_first_issue_read(resource.as_str(), rest.as_slice());
1061 }
1062 let resource = normalize_read_verb_resource(verb, resource.as_str());
1063 parse_read_resource(resource, rest.as_slice())
1064}
1065
1066fn recency_count_shortcut_args(verb: &str, args: &[String]) -> Option<Vec<String>> {
1068 if !is_recency_read_verb(verb) {
1069 return None;
1070 }
1071 let normalized = move_leading_json_to_tail(args);
1072 let (count, tail) = normalized.split_first().filter(|(count, tail)| {
1073 !tail.is_empty() && count.chars().all(|char| char.is_ascii_digit())
1074 })?;
1075 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1076 rewritten.push(tail[0].clone());
1077 let rest = &tail[1..];
1078 if let Some(separator_index) = rest.iter().position(|arg| arg == "--") {
1079 rewritten.extend(rest[..separator_index].iter().cloned());
1080 rewritten.push(String::from("--limit"));
1081 rewritten.push(count.clone());
1082 rewritten.extend(rest[separator_index..].iter().cloned());
1083 return Some(rewritten);
1084 }
1085 rewritten.extend(rest.iter().cloned());
1086 rewritten.push(String::from("--limit"));
1087 rewritten.push(count.clone());
1088 Some(rewritten)
1089}
1090
1091fn is_read_verb(value: &str) -> bool {
1093 matches!(value, "show" | "list" | "get") || is_recency_read_verb(value)
1094}
1095
1096fn is_recency_read_verb(value: &str) -> bool {
1098 matches!(value, "latest" | "recent" | "last" | "newest")
1099}
1100
1101fn normalize_read_verb_resource<'a>(verb: &str, resource: &'a str) -> &'a str {
1103 match (verb, resource) {
1104 ("list" | "show" | "get", "log") => "logs",
1105 (alias, "log") if is_recency_read_verb(alias) => "logs",
1106 (alias, "issue") if is_recency_read_verb(alias) => "issues",
1107 ("list", "issue") => "issues",
1108 ("list" | "show" | "get", "release") => "releases",
1109 (alias, "release") if is_recency_read_verb(alias) => "releases",
1110 _ => resource,
1111 }
1112}
1113
1114fn is_log_search_shortcut(command: &str) -> bool {
1116 matches!(command, "search" | "find" | "grep")
1117}
1118
1119fn is_log_search_separator_literal(command: &str, args: &[String]) -> bool {
1121 literal_log_search_separator_index(command, args).is_some()
1122}
1123
1124fn log_search_shortcut_label(command: &str) -> &'static str {
1126 match command {
1127 "find" => "find",
1128 "grep" => "grep",
1129 _ => "search",
1130 }
1131}
1132
1133fn parse_search_shortcut(label: &'static str, args: &[String]) -> Result<Command, CliError> {
1135 let (query, tail) = take_search_query(args, label)?;
1136 let mut rest = Vec::with_capacity(tail.len() + 2);
1137 if query.starts_with('-') {
1138 rest.push(format!("--search={query}"));
1139 } else {
1140 rest.push(String::from("--search"));
1141 rest.push(query);
1142 }
1143 rest.extend(tail);
1144 parse_read_resource("logs", rest.as_slice())
1145}
1146
1147fn take_search_query(
1149 args: &[String],
1150 argument: &'static str,
1151) -> Result<(String, Vec<String>), CliError> {
1152 let normalized = move_leading_json_to_tail(args);
1153 if normalized.first().is_some_and(|arg| arg == "--") {
1154 return take_separator_search_query(normalized.as_slice(), argument);
1155 }
1156 let query_word_count = normalized
1157 .iter()
1158 .take_while(|arg| !arg.starts_with('-'))
1159 .count();
1160 if query_word_count == 0 {
1161 return Err(CliError::MissingArgument {
1162 argument,
1163 next: SEARCH_NEXT_STEP,
1164 });
1165 }
1166 let query = normalized[..query_word_count].join(" ");
1167 let tail = normalized[query_word_count..].to_vec();
1168 Ok((query, tail))
1169}
1170
1171fn take_separator_search_query(
1173 args: &[String],
1174 argument: &'static str,
1175) -> Result<(String, Vec<String>), CliError> {
1176 let words = &args[1..];
1177 if words.is_empty() {
1178 return Err(CliError::MissingArgument {
1179 argument,
1180 next: SEARCH_NEXT_STEP,
1181 });
1182 }
1183 let has_trailing_json_mode = words.len() > 1 && words.last().is_some_and(|arg| arg == "--json");
1184 let query_end = if has_trailing_json_mode {
1185 words.len() - 1
1186 } else {
1187 words.len()
1188 };
1189 let query = words[..query_end].join(" ");
1190 let tail = if has_trailing_json_mode {
1191 vec![String::from("--json")]
1192 } else {
1193 Vec::new()
1194 };
1195 Ok((query, tail))
1196}
1197
1198fn parse_read_resource(resource: &str, rest: &[String]) -> Result<Command, CliError> {
1200 let (target, flags) = match resource {
1201 "logs" => parse_log_list_read(rest)?,
1202 alias if is_issue_collection_alias(alias) && has_issue_id_candidate(rest) => {
1203 return parse_issue_detail_or_status(rest);
1204 }
1205 alias if is_issue_collection_alias(alias) => parse_issue_list_read(rest)?,
1206 alias if is_action_collection_alias(alias) => parse_action_list_read(rest)?,
1207 "releases" => parse_list_read(
1208 ReadTarget::Releases,
1209 rest,
1210 "read releases",
1211 READ_RELEASES_NEXT_STEP,
1212 &[
1213 "--name",
1214 "--user",
1215 "--distinct-id",
1216 "--trace",
1217 "--trace-id",
1218 "--level",
1219 "--severity",
1220 "--search",
1221 "--status",
1222 "--min-duration-ms",
1223 ],
1224 )?,
1225 "traces" | "spans" if has_trace_id_candidate(rest) => {
1226 return parse_trace_detail_or_explain(rest);
1227 }
1228 "traces" | "spans" => parse_trace_list_read(rest)?,
1229 "trace" => return parse_trace_detail_or_explain(rest),
1230 "span" if has_position_candidate(rest) => {
1231 return parse_trace_detail_or_explain(rest);
1232 }
1233 "issue" if has_issue_status_candidate(rest) => parse_issue_list_read(rest)?,
1234 "issue" => return parse_issue_detail_or_status(rest),
1235 other => return Err(unknown_read_resource(other)),
1236 };
1237 let json = flags.is_json();
1238 let options = flags.into_read_options();
1239 validate_read_filters(&target, &options)?;
1240
1241 Ok(Command::Read {
1242 target,
1243 options: Box::new(options),
1244 json,
1245 })
1246}
1247
1248fn is_issue_collection_alias(value: &str) -> bool {
1250 matches!(
1251 value,
1252 "issues" | "errors" | "error" | "exceptions" | "exception"
1253 )
1254}
1255
1256fn is_status_first_issue_collection_alias(value: &str) -> bool {
1258 value == "issue" || is_issue_collection_alias(value)
1259}
1260
1261fn has_status_first_issue_resource_candidate(args: &[String]) -> bool {
1263 move_leading_json_to_tail(args)
1264 .first()
1265 .is_some_and(|arg| is_status_first_issue_collection_alias(arg))
1266}
1267
1268fn has_issue_status_candidate(args: &[String]) -> bool {
1270 move_leading_json_to_tail(args)
1271 .first()
1272 .is_some_and(|arg| is_known_issue_status(arg))
1273}
1274
1275fn is_action_collection_alias(value: &str) -> bool {
1277 matches!(value, "actions" | "events" | "event" | "action")
1278}
1279
1280fn parse_log_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1282 let args = log_shortcut_args(rest);
1283 parse_list_read(
1284 ReadTarget::Logs,
1285 args.as_slice(),
1286 "read logs",
1287 READ_LOGS_NEXT_STEP,
1288 &[
1289 "--name",
1290 "--user",
1291 "--distinct-id",
1292 "--status",
1293 "--min-duration-ms",
1294 ],
1295 )
1296}
1297
1298fn parse_issue_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1300 let args = issue_status_shortcut_args(rest);
1301 parse_list_read(
1302 ReadTarget::Issues,
1303 args.as_slice(),
1304 "read issues",
1305 READ_ISSUES_NEXT_STEP,
1306 &[
1307 "--name",
1308 "--user",
1309 "--distinct-id",
1310 "--trace",
1311 "--trace-id",
1312 "--level",
1313 "--severity",
1314 "--search",
1315 "--min-duration-ms",
1316 ],
1317 )
1318}
1319
1320fn parse_status_first_issue_read(status: &str, args: &[String]) -> Result<Command, CliError> {
1322 let canonical_status = normalize_status(status)?;
1323 let (resource, rest) = take_required_position(args, "resource", READ_ISSUES_NEXT_STEP)?;
1324 let resource = resource.as_str();
1325 if !is_status_first_issue_collection_alias(resource) {
1326 return Err(unknown_read_resource(resource));
1327 }
1328 let resource = if resource == "issue" {
1329 "issues"
1330 } else {
1331 resource
1332 };
1333 let mut rewritten = Vec::with_capacity(rest.len() + 2);
1334 rewritten.push(String::from("--status"));
1335 rewritten.push(canonical_status);
1336 rewritten.extend(rest);
1337 parse_read_resource(resource, rewritten.as_slice())
1338}
1339
1340fn issue_status_shortcut_args(args: &[String]) -> Vec<String> {
1342 let normalized = move_leading_json_to_tail(args);
1343 let Some((status, tail)) = normalized
1344 .split_first()
1345 .and_then(|(status, tail)| normalize_status(status).ok().map(|value| (value, tail)))
1346 else {
1347 return args.to_vec();
1348 };
1349 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1350 rewritten.push(String::from("--status"));
1351 rewritten.push(status);
1352 rewritten.extend(tail.iter().cloned());
1353 rewritten
1354}
1355
1356fn parse_action_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1358 let args = action_name_shortcut_args(rest);
1359 parse_list_read(
1360 ReadTarget::Actions,
1361 args.as_slice(),
1362 "read actions",
1363 READ_ACTIONS_NEXT_STEP,
1364 ACTION_LIST_UNSUPPORTED_FLAGS,
1365 )
1366}
1367
1368fn action_name_shortcut_args(args: &[String]) -> Vec<String> {
1370 let normalized = move_leading_json_to_tail(args);
1371 let Some((name, tail)) = normalized
1372 .split_first()
1373 .filter(|(name, _)| !name.starts_with('-') && !is_read_filter_word(name))
1374 else {
1375 return args.to_vec();
1376 };
1377 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
1378 rewritten.push(String::from("--name"));
1379 rewritten.push(name.clone());
1380 rewritten.extend(tail.iter().cloned());
1381 rewritten
1382}
1383
1384fn has_issue_id_candidate(args: &[String]) -> bool {
1386 move_leading_json_to_tail(args)
1387 .first()
1388 .is_some_and(|arg| is_issue_id(arg))
1389}
1390
1391fn parse_issue_detail_or_status(args: &[String]) -> Result<Command, CliError> {
1393 let (id, tail) = take_required_position(args, "issue_id", "provide an issue id")?;
1394 if has_issue_status_action(tail.as_slice()) {
1395 return parse_issue_first_status_shortcut(id, tail.as_slice());
1396 }
1397 if let Some(command) =
1398 parse_detail_explain_suffix(ExplainTarget::Issue(id.clone()), tail.as_slice())?
1399 {
1400 return Ok(command);
1401 }
1402 let target = ReadTarget::Issue(id);
1403 let flags = parse_detail_read_flags(
1404 tail.as_slice(),
1405 "read issue",
1406 READ_ISSUE_NEXT_STEP,
1407 ISSUE_DETAIL_UNSUPPORTED_FLAGS,
1408 )?;
1409 let json = flags.is_json();
1410 let options = flags.into_read_options();
1411 validate_read_filters(&target, &options)?;
1412
1413 Ok(Command::Read {
1414 target,
1415 options: Box::new(options),
1416 json,
1417 })
1418}
1419
1420fn parse_list_read(
1422 target: ReadTarget,
1423 args: &[String],
1424 command: &'static str,
1425 next: &'static str,
1426 unsupported_flags: &[&str],
1427) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
1428 reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
1429 Ok((target, parse_flags(args, FlagScope::Read)?))
1430}
1431
1432fn reject_unsupported_read_flags(
1434 args: &[String],
1435 command: &'static str,
1436 next: &'static str,
1437 unsupported_flags: &[&str],
1438) -> Result<(), CliError> {
1439 let mut index = 0;
1440 let mut seen = Vec::new();
1441 while let Some(arg) = args.get(index) {
1442 let (flag, inline_value) = arg
1443 .split_once('=')
1444 .map_or((arg.as_str(), None), |(name, value)| (name, Some(value)));
1445 if !is_read_value_flag(flag) {
1446 if flag == "--json" && inline_value.is_none() {
1447 if seen.contains(&"--json") {
1448 return Ok(());
1449 }
1450 seen.push("--json");
1451 index += 1;
1452 continue;
1453 }
1454 if inline_value.is_some() && is_simple_flag(flag) {
1455 return Err(CliError::UnsupportedFlag {
1456 flag: arg.to_owned(),
1457 command,
1458 next,
1459 });
1460 }
1461 if arg.starts_with('-') {
1462 return Err(unknown_flag(arg, next));
1463 }
1464 return Ok(());
1465 }
1466 if unsupported_flags.contains(&flag) {
1467 return Err(CliError::UnsupportedFlag {
1468 flag: user_facing_read_flag(flag).to_owned(),
1469 command,
1470 next,
1471 });
1472 }
1473 if let Some(canonical) = read_value_canonical_flag(flag) {
1474 if seen.contains(&canonical) {
1475 return Ok(());
1476 }
1477 seen.push(canonical);
1478 }
1479 if inline_value.is_some_and(str::is_empty) {
1480 return Ok(());
1481 }
1482 if inline_value.is_some_and(|value| has_invalid_supported_read_value(flag, value)) {
1483 return Ok(());
1484 }
1485 if inline_value.is_none() {
1486 let Some(value) = args.get(index + 1) else {
1487 return Ok(());
1488 };
1489 if value.starts_with('-') {
1490 return Ok(());
1491 }
1492 if has_invalid_supported_read_value(flag, value) {
1493 return Ok(());
1494 }
1495 index += 1;
1496 }
1497 index += 1;
1498 }
1499 Ok(())
1500}
1501
1502fn read_value_canonical_flag(flag: &str) -> Option<&'static str> {
1504 let canonical = match flag {
1505 "--name" => "--name",
1506 "--service" | "--service-name" => "--service",
1507 "--since" => "--since",
1508 "--user" | "--distinct-id" => "--user",
1509 "--trace" | "--trace-id" => "--trace",
1510 "--level" | "--severity" => "--severity",
1511 "--search" => "--search",
1512 "--project" | "--project-id" => "--project",
1513 "--release" => "--release",
1514 "--environment" | "--env" => "--environment",
1515 "--status" => "--status",
1516 "--limit" => "--limit",
1517 "--min-duration-ms" => "--min-duration-ms",
1518 "--pagination" => "--pagination",
1519 "--cursor-time" => "--cursor-time",
1520 "--cursor-id" => "--cursor-id",
1521 _ => return None,
1522 };
1523 Some(canonical)
1524}
1525
1526fn user_facing_read_flag(flag: &str) -> &str {
1528 match flag {
1529 "--level" => "--severity",
1530 "--service-name" => "--service",
1531 other => other,
1532 }
1533}
1534
1535fn has_invalid_supported_read_value(flag: &str, value: &str) -> bool {
1537 match flag {
1538 "--level" | "--severity" => !is_known_log_level(value),
1539 "--status" => !is_known_issue_status(value),
1540 "--limit" => value.parse::<u32>().map_or(true, |limit| limit == 0),
1541 "--min-duration-ms" => validate_min_duration(value).is_err(),
1542 "--pagination" => value != "cursor",
1543 _ => false,
1544 }
1545}
1546
1547fn is_known_log_level(value: &str) -> bool {
1549 normalize_log_level(value).is_ok()
1550}
1551
1552fn is_ambiguous_log_search_word(value: &str) -> bool {
1554 is_read_filter_word(value) || value.contains('@') || is_trace_id(value)
1555}
1556
1557fn is_known_issue_status(value: &str) -> bool {
1559 normalize_status(value).is_ok()
1560}
1561
1562fn is_read_value_flag(flag: &str) -> bool {
1564 matches!(
1565 flag,
1566 "--name"
1567 | "--service"
1568 | "--service-name"
1569 | "--since"
1570 | "--user"
1571 | "--distinct-id"
1572 | "--trace"
1573 | "--trace-id"
1574 | "--level"
1575 | "--severity"
1576 | "--search"
1577 | "--project"
1578 | "--project-id"
1579 | "--release"
1580 | "--environment"
1581 | "--env"
1582 | "--status"
1583 | "--limit"
1584 | "--min-duration-ms"
1585 | "--pagination"
1586 | "--cursor-time"
1587 | "--cursor-id"
1588 )
1589}
1590
1591fn parse_detail_explain_suffix(
1593 target: ExplainTarget,
1594 args: &[String],
1595) -> Result<Option<Command>, CliError> {
1596 let normalized = move_leading_json_to_tail(args);
1597 if normalized.first().is_none_or(|arg| arg != "explain") {
1598 return Ok(None);
1599 }
1600 Ok(Some(Command::Explain {
1601 target,
1602 json: parse_flags(&normalized[1..], FlagScope::Explain)?.is_json(),
1603 }))
1604}
1605
1606fn parse_detail_read_flags(
1608 args: &[String],
1609 command: &'static str,
1610 next: &'static str,
1611 unsupported_flags: &[&str],
1612) -> Result<crate::flags::Flags, CliError> {
1613 reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
1614 parse_flags(args, FlagScope::Read)
1615}
1616
1617fn parse_pasted_detail_id(id: &str, args: &[String]) -> Result<Command, CliError> {
1619 if is_issue_id(id) && has_issue_status_action(args) {
1620 return parse_issue_first_status_shortcut(id.to_owned(), args);
1621 }
1622 let explain_args = move_leading_json_to_tail(args);
1623 if explain_args.first().is_some_and(|arg| arg == "explain") {
1624 let target = infer_explain_target(id).ok_or_else(|| unknown_command(id))?;
1625 return Ok(Command::Explain {
1626 target,
1627 json: parse_flags(&explain_args[1..], FlagScope::Explain)?.is_json(),
1628 });
1629 }
1630 let (target, flags) = if is_trace_id(id) {
1631 (
1632 ReadTarget::Trace(id.to_owned()),
1633 parse_detail_read_flags(
1634 args,
1635 "read trace",
1636 READ_TRACE_NEXT_STEP,
1637 TRACE_DETAIL_UNSUPPORTED_FLAGS,
1638 )?,
1639 )
1640 } else if is_issue_id(id) {
1641 (
1642 ReadTarget::Issue(id.to_owned()),
1643 parse_detail_read_flags(
1644 args,
1645 "read issue",
1646 READ_ISSUE_NEXT_STEP,
1647 ISSUE_DETAIL_UNSUPPORTED_FLAGS,
1648 )?,
1649 )
1650 } else {
1651 return Err(unknown_command(id));
1652 };
1653 let json = flags.is_json();
1654 let options = flags.into_read_options();
1655 validate_read_filters(&target, &options)?;
1656
1657 Ok(Command::Read {
1658 target,
1659 options: Box::new(options),
1660 json,
1661 })
1662}
1663
1664fn validate_read_filters(target: &ReadTarget, filters: &ReadOptions) -> Result<(), CliError> {
1666 let unsupported = match target {
1667 ReadTarget::Logs => filters
1668 .first_log_unsupported_flag()
1669 .map(|flag| (flag, "read logs", READ_LOGS_NEXT_STEP)),
1670 ReadTarget::Issues => filters
1671 .first_issue_list_unsupported_flag()
1672 .map(|flag| (flag, "read issues", READ_ISSUES_NEXT_STEP)),
1673 ReadTarget::Actions => filters
1674 .first_action_unsupported_flag()
1675 .map(|flag| (flag, "read actions", READ_ACTIONS_NEXT_STEP)),
1676 ReadTarget::Releases => filters
1677 .first_release_unsupported_flag()
1678 .map(|flag| (flag, "read releases", READ_RELEASES_NEXT_STEP)),
1679 ReadTarget::Traces => filters
1680 .first_trace_list_unsupported_flag()
1681 .map(|flag| (flag, "read traces", READ_TRACES_NEXT_STEP)),
1682 ReadTarget::Trace(_) => filters
1683 .first_trace_detail_unsupported_flag()
1684 .map(|flag| (flag, "read trace", READ_TRACE_NEXT_STEP)),
1685 ReadTarget::Issue(_) => filters
1686 .first_issue_detail_unsupported_flag()
1687 .map(|flag| (flag, "read issue", READ_ISSUE_NEXT_STEP)),
1688 };
1689
1690 if let Some((flag, command, next)) = unsupported {
1691 return Err(CliError::UnsupportedFlag {
1692 flag: flag.to_owned(),
1693 command,
1694 next,
1695 });
1696 }
1697 match target {
1698 ReadTarget::Logs => validate_read_cursor(filters, CliError::InvalidLogCursor)?,
1699 ReadTarget::Actions => validate_read_cursor(filters, CliError::InvalidActionCursor)?,
1700 ReadTarget::Issues => validate_read_cursor(filters, CliError::InvalidIssueCursor)?,
1701 ReadTarget::Releases | ReadTarget::Traces | ReadTarget::Trace(_) | ReadTarget::Issue(_) => {
1702 }
1703 }
1704 Ok(())
1705}
1706
1707fn validate_read_cursor(
1709 filters: &ReadOptions,
1710 invalid_cursor: fn(String) -> CliError,
1711) -> Result<(), CliError> {
1712 match (
1713 filters.pagination.as_deref(),
1714 filters.cursor_time.as_ref(),
1715 filters.cursor_id.as_ref(),
1716 ) {
1717 (None | Some("cursor"), None, None) | (Some("cursor"), Some(_), Some(_)) => Ok(()),
1718 (None, _, _) => Err(invalid_cursor(String::from(
1719 "cursor fields require --pagination cursor",
1720 ))),
1721 (Some("cursor"), _, _) => Err(invalid_cursor(String::from(
1722 "--cursor-time and --cursor-id must be used together",
1723 ))),
1724 (Some(_), _, _) => Err(CliError::UnknownPagination),
1725 }
1726}
1727
1728fn parse_explain(args: &[String]) -> Result<Command, CliError> {
1730 let (resource, rest) = take_required_position(args, "resource", EXPLAIN_RESOURCE_NEXT_STEP)?;
1731 let (target, tail) = match resource.as_str() {
1732 "issue" => {
1733 let (id, tail) =
1734 take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1735 (ExplainTarget::Issue(id), tail)
1736 }
1737 "trace" => {
1738 let (id, tail) =
1739 take_required_position(rest.as_slice(), "trace_id", "provide a trace id")?;
1740 (ExplainTarget::Trace(id), tail)
1741 }
1742 other => {
1743 if let Some(target) = infer_explain_target(other) {
1744 (target, rest)
1745 } else {
1746 return Err(unknown_resource(other, EXPLAIN_RESOURCE_NEXT_STEP));
1747 }
1748 }
1749 };
1750 let flags = parse_flags(tail.as_slice(), FlagScope::Explain)?;
1751 Ok(Command::Explain {
1752 target,
1753 json: flags.is_json(),
1754 })
1755}
1756
1757fn parse_set(args: &[String]) -> Result<Command, CliError> {
1759 let (resource, rest) = take_required_position(args, "resource", SET_RESOURCE_NEXT_STEP)?;
1760 if resource != "issue" {
1761 return Err(unknown_resource(resource.as_str(), SET_RESOURCE_NEXT_STEP));
1762 }
1763 let (id, rest) = take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1764 let (status, tail) =
1765 take_required_position(rest.as_slice(), "status", ISSUE_STATUS_ARGUMENT_NEXT_STEP)?;
1766 let status = normalize_status(status.as_str())?;
1767 let flags = parse_flags(tail.as_slice(), FlagScope::Set)?;
1768
1769 Ok(Command::Set {
1770 target: SetTarget::IssueStatus { id, status },
1771 json: flags.is_json(),
1772 })
1773}