1mod help_topics;
4mod issue_shortcuts;
5mod log_shortcuts;
6mod watch;
7
8use help_topics::{
9 command_shaped_help_topic, contains_help_flag, ensure_no_help_positionals, help_command,
10 help_topic, is_direct_filter_help_alias, is_help_flag, parse_help, parse_help_alias,
11 parse_literal_help, positional_args, validate_help_flags,
12};
13use issue_shortcuts::{
14 has_issue_status_action, is_issue_status_action_alias, parse_bare_issue_status_shortcut,
15 parse_issue_first_status_shortcut, parse_issue_status_shortcut,
16 parse_status_first_issue_id_shortcut,
17};
18use log_shortcuts::{literal_log_search_separator_index, log_shortcut_args};
19use watch::parse_watch;
20
21use crate::flags::{
22 FlagScope, is_read_filter_word, is_simple_flag, normalize_log_level, normalize_status,
23 parse_flags,
24};
25use crate::ids::{infer_explain_target, is_issue_id, is_pasted_detail_id, is_trace_id};
26use crate::{
27 CliError, Command, ExplainTarget, HelpTopic, ISSUE_STATUS_ARGUMENT_NEXT_STEP, ReadOptions,
28 ReadTarget, SetTarget, auth_namespace,
29};
30
31const HELP_NEXT_STEP: &str = "run logbrew --help";
33const READ_RESOURCE_NEXT_STEP: &str = "choose one of logs, issues, actions, releases, trace, issue";
35const READ_TRACE_ALIAS_NEXT_STEP: &str =
37 "use singular trace with an id: logbrew read trace <trace_id>";
38const TRACE_COMMAND_NEXT_STEP: &str =
40 "use logbrew trace <trace_id> or logbrew explain trace <trace_id>";
41const READ_TRACE_NEXT_STEP: &str = "run logbrew read trace --help";
43const READ_ISSUE_NEXT_STEP: &str = "run logbrew read issue --help";
45const READ_LOGS_NEXT_STEP: &str = "run logbrew read logs --help";
47const SEARCH_NEXT_STEP: &str = "provide search text or run logbrew logs --help";
49const READ_ISSUES_NEXT_STEP: &str = "run logbrew read issues --help";
51const READ_ACTIONS_NEXT_STEP: &str = "run logbrew read actions --help";
53const READ_RELEASES_NEXT_STEP: &str = "run logbrew read releases --help";
55const WATCH_RESOURCE_NEXT_STEP: &str = "choose logs or actions";
57const EXPLAIN_RESOURCE_NEXT_STEP: &str = "choose issue or trace";
59const SET_RESOURCE_NEXT_STEP: &str = "choose issue";
61const TRACE_DETAIL_UNSUPPORTED_FLAGS: &[&str] = &[
63 "--name",
64 "--since",
65 "--user",
66 "--distinct-id",
67 "--trace",
68 "--trace-id",
69 "--level",
70 "--severity",
71 "--search",
72 "--status",
73 "--limit",
74];
75const ISSUE_DETAIL_UNSUPPORTED_FLAGS: &[&str] = &[
77 "--name",
78 "--since",
79 "--user",
80 "--distinct-id",
81 "--trace",
82 "--trace-id",
83 "--level",
84 "--severity",
85 "--search",
86 "--project",
87 "--project-id",
88 "--release",
89 "--environment",
90 "--env",
91 "--status",
92 "--limit",
93];
94const ACTION_LIST_UNSUPPORTED_FLAGS: &[&str] = &[
96 "--trace",
97 "--trace-id",
98 "--level",
99 "--severity",
100 "--search",
101 "--status",
102];
103
104pub fn parse_command<I, S>(args: I) -> Result<Command, CliError>
107where
108 I: IntoIterator<Item = S>,
109 S: AsRef<str>,
110{
111 let values = args
112 .into_iter()
113 .map(|arg| arg.as_ref().to_owned())
114 .collect::<Vec<_>>();
115 parse_values(values.as_slice())
116}
117
118fn parse_values(values: &[String]) -> Result<Command, CliError> {
120 let args = values.get(1..).ok_or(CliError::UnknownCommand)?;
121 let Some((head, tail)) = args.split_first() else {
122 return Ok(Command::Help {
123 topic: HelpTopic::Root,
124 json: false,
125 });
126 };
127 if is_help_flag(head) {
128 validate_help_flags(tail)?;
129 ensure_no_help_positionals(positional_args(tail).as_slice())?;
130 return Ok(help_command(HelpTopic::Root, tail));
131 }
132 if head == "--json" {
133 return parse_global_json(values, tail);
134 }
135 if is_version_flag(head) {
136 return parse_version(tail);
137 }
138 if head.starts_with('-') {
139 return Err(unknown_flag(head, HELP_NEXT_STEP));
140 }
141 if head == "help" {
142 return parse_help(tail);
143 }
144 if let Some(command) = parse_literal_help(head, tail)? {
145 return Ok(command);
146 }
147 if contains_help_flag(tail) && !is_log_search_separator_literal(head, tail) {
148 validate_help_flags(tail)?;
149 if let Some(topic) = command_shaped_help_topic(head, tail) {
150 return Ok(help_command(topic, tail));
151 }
152 return Ok(help_command(help_topic(head, tail)?, tail));
153 }
154 match head.as_str() {
155 "login" => parse_login(tail),
156 "logout" => parse_logout(tail),
157 alias if is_setup_alias(alias) => parse_setup(tail),
158 "status" | "whoami" | "me" | "health" | "ping" | "doctor" => parse_status(tail),
159 "version" => parse_version(tail),
160 alias if auth_namespace::is_namespace(alias) => auth_namespace::parse(tail),
161 alias if auth_namespace::is_help_alias(alias) => parse_help_alias(HelpTopic::Auth, tail),
162 "json" | "output" => parse_help_alias(HelpTopic::Json, tail),
163 alias if is_direct_filter_help_alias(alias) => parse_help_alias(HelpTopic::Read, tail),
164 "read" => parse_read(tail),
165 alias if is_read_verb(alias) => parse_read_verb(alias, tail),
166 status if is_known_issue_status(status) && has_issue_id_candidate(tail) => {
167 parse_status_first_issue_id_shortcut(status, tail)
168 }
169 status
170 if is_known_issue_status(status) && has_status_first_issue_resource_candidate(tail) =>
171 {
172 parse_status_first_issue_read(status, tail)
173 }
174 status if is_known_issue_status(status) => parse_bare_issue_status_shortcut(status, tail),
175 alias if is_log_search_shortcut(alias) => {
176 parse_search_shortcut(log_search_shortcut_label(alias), tail)
177 }
178 "log" => parse_read_resource("logs", tail),
179 "release" => parse_read_resource("releases", tail),
180 alias if is_trace_term(alias) && !has_position_candidate(tail) => {
181 parse_help_alias(HelpTopic::ReadTrace, tail)
182 }
183 "logs" | "issues" | "errors" | "error" | "exceptions" | "exception" | "actions"
184 | "events" | "event" | "action" | "releases" | "trace" | "issue" => {
185 parse_read_resource(head, tail)
186 }
187 "traces" | "span" | "spans" if has_position_candidate(tail) => {
188 parse_read_resource("trace", tail)
189 }
190 "resolve" | "close" | "ignore" | "reopen" => parse_issue_status_shortcut(head, tail),
191 alias if is_watch_command_alias(alias) => parse_watch(tail),
192 "explain" => parse_explain(tail),
193 "set" => parse_set(tail),
194 id if is_pasted_detail_id(id) => parse_pasted_detail_id(id, tail),
195 _ => Err(unknown_command(head)),
196 }
197}
198
199fn parse_global_json(values: &[String], tail: &[String]) -> Result<Command, CliError> {
201 if global_json_tail_has_duplicate(tail) {
202 return Err(CliError::DuplicateFlag {
203 flag: "--json",
204 next: "use --json once",
205 });
206 }
207 if tail.is_empty() {
208 return Ok(Command::Help {
209 topic: HelpTopic::Root,
210 json: true,
211 });
212 }
213
214 let mut normalized = Vec::with_capacity(values.len());
215 if let Some(program) = values.first() {
216 normalized.push(program.clone());
217 }
218 normalized.extend(tail.iter().cloned());
219 normalized.push(String::from("--json"));
220 parse_values(normalized.as_slice())
221}
222
223fn global_json_tail_has_duplicate(tail: &[String]) -> bool {
225 if !tail.iter().any(|arg| arg == "--json") {
226 return false;
227 }
228 let Some((command, rest)) = tail.split_first() else {
229 return false;
230 };
231 let Some(separator_index) = literal_log_search_separator_index(command, rest) else {
232 return true;
233 };
234 rest[..separator_index].iter().any(|arg| arg == "--json")
235}
236fn unknown_resource(resource: &str, next: &'static str) -> CliError {
238 CliError::UnknownResource {
239 resource: resource.to_owned(),
240 next,
241 }
242}
243
244fn unknown_flag(flag: &str, next: &'static str) -> CliError {
246 CliError::UnknownFlag {
247 flag: flag.to_owned(),
248 next,
249 }
250}
251
252fn unknown_read_resource(resource: &str) -> CliError {
254 unknown_resource(resource, read_resource_next_step(resource))
255}
256
257fn read_resource_next_step(resource: &str) -> &'static str {
259 match resource {
260 "trace" | "traces" | "span" | "spans" => READ_TRACE_ALIAS_NEXT_STEP,
261 _ => READ_RESOURCE_NEXT_STEP,
262 }
263}
264
265fn unknown_command(command: &str) -> CliError {
267 CliError::UnknownCommandName {
268 command: command.to_owned(),
269 next: unknown_command_next_step(command),
270 }
271}
272
273fn unknown_command_next_step(command: &str) -> &'static str {
275 match command {
276 "logg" | "lgs" => "did you mean logbrew logs?",
277 "action" | "event" | "events" => "did you mean logbrew actions?",
278 "releaze" | "rels" => "did you mean logbrew releases?",
279 "statuz" | "stats" => "did you mean logbrew status?",
280 "error" | "errors" | "exception" | "exceptions" => "did you mean logbrew issues?",
281 "trace" | "traces" | "span" | "spans" => TRACE_COMMAND_NEXT_STEP,
282 "env" | "environment" | "environments" => {
283 "use --environment <environment> with logs, issues, actions, releases, or traces"
284 }
285 alias if auth_namespace::is_help_alias(alias) => "run logbrew help auth",
286 _ => HELP_NEXT_STEP,
287 }
288}
289
290fn is_status_help_alias(value: &str) -> bool {
292 matches!(value, "status" | "health" | "ping" | "doctor")
293}
294
295fn is_setup_alias(value: &str) -> bool {
297 matches!(value, "setup" | "init" | "install" | "configure" | "sdk")
298}
299
300fn is_watch_command_alias(value: &str) -> bool {
302 matches!(value, "watch" | "tail" | "follow" | "stream")
303}
304
305fn is_trace_term(value: &str) -> bool {
307 matches!(value, "trace" | "traces" | "span" | "spans")
308}
309
310fn is_version_flag(value: &str) -> bool {
312 matches!(value, "--version" | "-V")
313}
314
315fn parse_login(args: &[String]) -> Result<Command, CliError> {
317 let flags = parse_flags(args, FlagScope::Login)?;
318 let json = flags.is_json();
319 Ok(Command::Login {
320 open_browser: flags.should_open_browser() && !json,
321 json,
322 })
323}
324
325fn parse_logout(args: &[String]) -> Result<Command, CliError> {
327 let flags = parse_flags(args, FlagScope::Logout)?;
328 Ok(Command::Logout {
329 json: flags.is_json(),
330 })
331}
332
333fn parse_setup(args: &[String]) -> Result<Command, CliError> {
335 let flags = parse_flags(args, FlagScope::Setup)?;
336 Ok(Command::Setup {
337 auto: flags.is_auto(),
338 yes: flags.skip_prompts(),
339 json: flags.is_json(),
340 })
341}
342
343fn parse_status(args: &[String]) -> Result<Command, CliError> {
345 let flags = parse_flags(args, FlagScope::Status)?;
346 Ok(Command::Status {
347 json: flags.is_json(),
348 })
349}
350
351fn parse_version(args: &[String]) -> Result<Command, CliError> {
353 let flags = parse_flags(args, FlagScope::Version)?;
354 Ok(Command::Version {
355 json: flags.is_json(),
356 })
357}
358
359fn take_required_arg<'a>(
361 args: &'a [String],
362 argument: &'static str,
363 next: &'static str,
364) -> Result<(&'a str, &'a [String]), CliError> {
365 let Some((value, rest)) = args.split_first() else {
366 return Err(CliError::MissingArgument { argument, next });
367 };
368 if value.starts_with('-') {
369 return Err(CliError::MissingArgument { argument, next });
370 }
371 Ok((value.as_str(), rest))
372}
373
374fn move_leading_json_to_tail(args: &[String]) -> Vec<String> {
376 if args.first().is_some_and(|arg| arg == "--json") {
377 let mut normalized = Vec::with_capacity(args.len());
378 normalized.extend(args[1..].iter().cloned());
379 normalized.push(String::from("--json"));
380 normalized
381 } else {
382 args.to_vec()
383 }
384}
385
386fn has_position_candidate(args: &[String]) -> bool {
388 move_leading_json_to_tail(args)
389 .first()
390 .is_some_and(|arg| !arg.starts_with('-'))
391}
392
393fn take_required_position(
395 args: &[String],
396 argument: &'static str,
397 next: &'static str,
398) -> Result<(String, Vec<String>), CliError> {
399 let normalized = move_leading_json_to_tail(args);
400 let (value, rest) = take_required_arg(normalized.as_slice(), argument, next)?;
401 Ok((value.to_owned(), rest.to_vec()))
402}
403
404fn parse_read(args: &[String]) -> Result<Command, CliError> {
406 let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
407 let resource = normalize_read_resource(resource.as_str());
408 if is_recency_read_verb(resource) {
409 return parse_read_verb(resource, rest.as_slice());
410 }
411 if is_known_issue_status(resource) && has_status_first_issue_resource_candidate(rest.as_slice())
412 {
413 return parse_status_first_issue_read(resource, rest.as_slice());
414 }
415 parse_read_resource(resource, rest.as_slice())
416}
417
418fn normalize_read_resource(resource: &str) -> &str {
420 match resource {
421 "log" => "logs",
422 "release" => "releases",
423 _ => resource,
424 }
425}
426
427fn parse_read_verb(verb: &str, args: &[String]) -> Result<Command, CliError> {
429 let rewritten_args = recency_count_shortcut_args(verb, args);
430 let args = rewritten_args.as_deref().unwrap_or(args);
431 let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
432 if is_known_issue_status(resource.as_str())
433 && has_status_first_issue_resource_candidate(rest.as_slice())
434 {
435 return parse_status_first_issue_read(resource.as_str(), rest.as_slice());
436 }
437 let resource = normalize_read_verb_resource(verb, resource.as_str());
438 parse_read_resource(resource, rest.as_slice())
439}
440
441fn recency_count_shortcut_args(verb: &str, args: &[String]) -> Option<Vec<String>> {
443 if !is_recency_read_verb(verb) {
444 return None;
445 }
446 let normalized = move_leading_json_to_tail(args);
447 let (count, tail) = normalized.split_first().filter(|(count, tail)| {
448 !tail.is_empty() && count.chars().all(|char| char.is_ascii_digit())
449 })?;
450 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
451 rewritten.push(tail[0].clone());
452 let rest = &tail[1..];
453 if let Some(separator_index) = rest.iter().position(|arg| arg == "--") {
454 rewritten.extend(rest[..separator_index].iter().cloned());
455 rewritten.push(String::from("--limit"));
456 rewritten.push(count.clone());
457 rewritten.extend(rest[separator_index..].iter().cloned());
458 return Some(rewritten);
459 }
460 rewritten.extend(rest.iter().cloned());
461 rewritten.push(String::from("--limit"));
462 rewritten.push(count.clone());
463 Some(rewritten)
464}
465
466fn is_read_verb(value: &str) -> bool {
468 matches!(value, "show" | "list" | "get") || is_recency_read_verb(value)
469}
470
471fn is_recency_read_verb(value: &str) -> bool {
473 matches!(value, "latest" | "recent" | "last" | "newest")
474}
475
476fn normalize_read_verb_resource<'a>(verb: &str, resource: &'a str) -> &'a str {
478 match (verb, resource) {
479 ("list" | "show" | "get", "log") => "logs",
480 (alias, "log") if is_recency_read_verb(alias) => "logs",
481 (alias, "issue") if is_recency_read_verb(alias) => "issues",
482 ("list", "issue") => "issues",
483 ("list" | "show" | "get", "release") => "releases",
484 (alias, "release") if is_recency_read_verb(alias) => "releases",
485 _ => resource,
486 }
487}
488
489fn is_log_search_shortcut(command: &str) -> bool {
491 matches!(command, "search" | "find" | "grep")
492}
493
494fn is_log_search_separator_literal(command: &str, args: &[String]) -> bool {
496 literal_log_search_separator_index(command, args).is_some()
497}
498
499fn log_search_shortcut_label(command: &str) -> &'static str {
501 match command {
502 "find" => "find",
503 "grep" => "grep",
504 _ => "search",
505 }
506}
507
508fn parse_search_shortcut(label: &'static str, args: &[String]) -> Result<Command, CliError> {
510 let (query, tail) = take_search_query(args, label)?;
511 let mut rest = Vec::with_capacity(tail.len() + 2);
512 if query.starts_with('-') {
513 rest.push(format!("--search={query}"));
514 } else {
515 rest.push(String::from("--search"));
516 rest.push(query);
517 }
518 rest.extend(tail);
519 parse_read_resource("logs", rest.as_slice())
520}
521
522fn take_search_query(
524 args: &[String],
525 argument: &'static str,
526) -> Result<(String, Vec<String>), CliError> {
527 let normalized = move_leading_json_to_tail(args);
528 if normalized.first().is_some_and(|arg| arg == "--") {
529 return take_separator_search_query(normalized.as_slice(), argument);
530 }
531 let query_word_count = normalized
532 .iter()
533 .take_while(|arg| !arg.starts_with('-'))
534 .count();
535 if query_word_count == 0 {
536 return Err(CliError::MissingArgument {
537 argument,
538 next: SEARCH_NEXT_STEP,
539 });
540 }
541 let query = normalized[..query_word_count].join(" ");
542 let tail = normalized[query_word_count..].to_vec();
543 Ok((query, tail))
544}
545
546fn take_separator_search_query(
548 args: &[String],
549 argument: &'static str,
550) -> Result<(String, Vec<String>), CliError> {
551 let words = &args[1..];
552 if words.is_empty() {
553 return Err(CliError::MissingArgument {
554 argument,
555 next: SEARCH_NEXT_STEP,
556 });
557 }
558 let has_trailing_json_mode = words.len() > 1 && words.last().is_some_and(|arg| arg == "--json");
559 let query_end = if has_trailing_json_mode {
560 words.len() - 1
561 } else {
562 words.len()
563 };
564 let query = words[..query_end].join(" ");
565 let tail = if has_trailing_json_mode {
566 vec![String::from("--json")]
567 } else {
568 Vec::new()
569 };
570 Ok((query, tail))
571}
572
573fn parse_read_resource(resource: &str, rest: &[String]) -> Result<Command, CliError> {
575 let (target, flags) = match resource {
576 "logs" => parse_log_list_read(rest)?,
577 alias if is_issue_collection_alias(alias) && has_issue_id_candidate(rest) => {
578 return parse_issue_detail_or_status(rest);
579 }
580 alias if is_issue_collection_alias(alias) => parse_issue_list_read(rest)?,
581 alias if is_action_collection_alias(alias) => parse_action_list_read(rest)?,
582 "releases" => parse_list_read(
583 ReadTarget::Releases,
584 rest,
585 "read releases",
586 READ_RELEASES_NEXT_STEP,
587 &[
588 "--name",
589 "--since",
590 "--user",
591 "--distinct-id",
592 "--trace",
593 "--trace-id",
594 "--level",
595 "--severity",
596 "--search",
597 "--status",
598 ],
599 )?,
600 "trace" => return parse_trace_detail_or_explain(rest),
601 "traces" | "span" | "spans" if has_position_candidate(rest) => {
602 return parse_trace_detail_or_explain(rest);
603 }
604 "issue" if has_issue_status_candidate(rest) => parse_issue_list_read(rest)?,
605 "issue" => return parse_issue_detail_or_status(rest),
606 other => return Err(unknown_read_resource(other)),
607 };
608 let json = flags.is_json();
609 let options = flags.into_read_options();
610 validate_read_filters(&target, &options)?;
611
612 Ok(Command::Read {
613 target,
614 options: Box::new(options),
615 json,
616 })
617}
618
619fn is_issue_collection_alias(value: &str) -> bool {
621 matches!(
622 value,
623 "issues" | "errors" | "error" | "exceptions" | "exception"
624 )
625}
626
627fn is_status_first_issue_collection_alias(value: &str) -> bool {
629 value == "issue" || is_issue_collection_alias(value)
630}
631
632fn has_status_first_issue_resource_candidate(args: &[String]) -> bool {
634 move_leading_json_to_tail(args)
635 .first()
636 .is_some_and(|arg| is_status_first_issue_collection_alias(arg))
637}
638
639fn has_issue_status_candidate(args: &[String]) -> bool {
641 move_leading_json_to_tail(args)
642 .first()
643 .is_some_and(|arg| is_known_issue_status(arg))
644}
645
646fn is_action_collection_alias(value: &str) -> bool {
648 matches!(value, "actions" | "events" | "event" | "action")
649}
650
651fn parse_log_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
653 let args = log_shortcut_args(rest);
654 parse_list_read(
655 ReadTarget::Logs,
656 args.as_slice(),
657 "read logs",
658 READ_LOGS_NEXT_STEP,
659 &["--name", "--user", "--distinct-id", "--status"],
660 )
661}
662
663fn parse_issue_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
665 let args = issue_status_shortcut_args(rest);
666 parse_list_read(
667 ReadTarget::Issues,
668 args.as_slice(),
669 "read issues",
670 READ_ISSUES_NEXT_STEP,
671 &[
672 "--name",
673 "--since",
674 "--user",
675 "--distinct-id",
676 "--trace",
677 "--trace-id",
678 "--level",
679 "--severity",
680 "--search",
681 ],
682 )
683}
684
685fn parse_status_first_issue_read(status: &str, args: &[String]) -> Result<Command, CliError> {
687 let canonical_status = normalize_status(status)?;
688 let (resource, rest) = take_required_position(args, "resource", READ_ISSUES_NEXT_STEP)?;
689 let resource = resource.as_str();
690 if !is_status_first_issue_collection_alias(resource) {
691 return Err(unknown_read_resource(resource));
692 }
693 let resource = if resource == "issue" {
694 "issues"
695 } else {
696 resource
697 };
698 let mut rewritten = Vec::with_capacity(rest.len() + 2);
699 rewritten.push(String::from("--status"));
700 rewritten.push(canonical_status);
701 rewritten.extend(rest);
702 parse_read_resource(resource, rewritten.as_slice())
703}
704
705fn issue_status_shortcut_args(args: &[String]) -> Vec<String> {
707 let normalized = move_leading_json_to_tail(args);
708 let Some((status, tail)) = normalized
709 .split_first()
710 .and_then(|(status, tail)| normalize_status(status).ok().map(|value| (value, tail)))
711 else {
712 return args.to_vec();
713 };
714 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
715 rewritten.push(String::from("--status"));
716 rewritten.push(status);
717 rewritten.extend(tail.iter().cloned());
718 rewritten
719}
720
721fn parse_action_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
723 let args = action_name_shortcut_args(rest);
724 parse_list_read(
725 ReadTarget::Actions,
726 args.as_slice(),
727 "read actions",
728 READ_ACTIONS_NEXT_STEP,
729 ACTION_LIST_UNSUPPORTED_FLAGS,
730 )
731}
732
733fn action_name_shortcut_args(args: &[String]) -> Vec<String> {
735 let normalized = move_leading_json_to_tail(args);
736 let Some((name, tail)) = normalized
737 .split_first()
738 .filter(|(name, _)| !name.starts_with('-') && !is_read_filter_word(name))
739 else {
740 return args.to_vec();
741 };
742 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
743 rewritten.push(String::from("--name"));
744 rewritten.push(name.clone());
745 rewritten.extend(tail.iter().cloned());
746 rewritten
747}
748
749fn has_issue_id_candidate(args: &[String]) -> bool {
751 move_leading_json_to_tail(args)
752 .first()
753 .is_some_and(|arg| is_issue_id(arg))
754}
755
756fn parse_issue_detail_or_status(args: &[String]) -> Result<Command, CliError> {
758 let (id, tail) = take_required_position(args, "issue_id", "provide an issue id")?;
759 if has_issue_status_action(tail.as_slice()) {
760 return parse_issue_first_status_shortcut(id, tail.as_slice());
761 }
762 if let Some(command) =
763 parse_detail_explain_suffix(ExplainTarget::Issue(id.clone()), tail.as_slice())?
764 {
765 return Ok(command);
766 }
767 let target = ReadTarget::Issue(id);
768 let flags = parse_detail_read_flags(
769 tail.as_slice(),
770 "read issue",
771 READ_ISSUE_NEXT_STEP,
772 ISSUE_DETAIL_UNSUPPORTED_FLAGS,
773 )?;
774 let json = flags.is_json();
775 let options = flags.into_read_options();
776 validate_read_filters(&target, &options)?;
777
778 Ok(Command::Read {
779 target,
780 options: Box::new(options),
781 json,
782 })
783}
784
785fn parse_list_read(
787 target: ReadTarget,
788 args: &[String],
789 command: &'static str,
790 next: &'static str,
791 unsupported_flags: &[&str],
792) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
793 reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
794 Ok((target, parse_flags(args, FlagScope::Read)?))
795}
796
797fn reject_unsupported_read_flags(
799 args: &[String],
800 command: &'static str,
801 next: &'static str,
802 unsupported_flags: &[&str],
803) -> Result<(), CliError> {
804 let mut index = 0;
805 let mut seen = Vec::new();
806 while let Some(arg) = args.get(index) {
807 let (flag, inline_value) = arg
808 .split_once('=')
809 .map_or((arg.as_str(), None), |(name, value)| (name, Some(value)));
810 if !is_read_value_flag(flag) {
811 if flag == "--json" && inline_value.is_none() {
812 if seen.contains(&"--json") {
813 return Ok(());
814 }
815 seen.push("--json");
816 index += 1;
817 continue;
818 }
819 if inline_value.is_some() && is_simple_flag(flag) {
820 return Err(CliError::UnsupportedFlag {
821 flag: arg.to_owned(),
822 command,
823 next,
824 });
825 }
826 if arg.starts_with('-') {
827 return Err(unknown_flag(arg, next));
828 }
829 return Ok(());
830 }
831 if unsupported_flags.contains(&flag) {
832 return Err(CliError::UnsupportedFlag {
833 flag: flag.to_owned(),
834 command,
835 next,
836 });
837 }
838 if let Some(canonical) = read_value_canonical_flag(flag) {
839 if seen.contains(&canonical) {
840 return Ok(());
841 }
842 seen.push(canonical);
843 }
844 if inline_value.is_some_and(str::is_empty) {
845 return Ok(());
846 }
847 if inline_value.is_some_and(|value| has_invalid_supported_read_value(flag, value)) {
848 return Ok(());
849 }
850 if inline_value.is_none() {
851 let Some(value) = args.get(index + 1) else {
852 return Ok(());
853 };
854 if value.starts_with('-') {
855 return Ok(());
856 }
857 if has_invalid_supported_read_value(flag, value) {
858 return Ok(());
859 }
860 index += 1;
861 }
862 index += 1;
863 }
864 Ok(())
865}
866
867fn read_value_canonical_flag(flag: &str) -> Option<&'static str> {
869 let canonical = match flag {
870 "--name" => "--name",
871 "--since" => "--since",
872 "--user" | "--distinct-id" => "--user",
873 "--trace" | "--trace-id" => "--trace",
874 "--level" | "--severity" => "--level",
875 "--search" => "--search",
876 "--project" | "--project-id" => "--project",
877 "--release" => "--release",
878 "--environment" | "--env" => "--environment",
879 "--status" => "--status",
880 "--limit" => "--limit",
881 _ => return None,
882 };
883 Some(canonical)
884}
885
886fn has_invalid_supported_read_value(flag: &str, value: &str) -> bool {
888 match flag {
889 "--level" | "--severity" => !is_known_log_level(value),
890 "--status" => !is_known_issue_status(value),
891 "--limit" => value.parse::<u32>().map_or(true, |limit| limit == 0),
892 _ => false,
893 }
894}
895
896fn is_known_log_level(value: &str) -> bool {
898 normalize_log_level(value).is_ok()
899}
900
901fn is_ambiguous_log_search_word(value: &str) -> bool {
903 is_read_filter_word(value) || value.contains('@') || is_trace_id(value)
904}
905
906fn is_known_issue_status(value: &str) -> bool {
908 normalize_status(value).is_ok()
909}
910
911fn is_read_value_flag(flag: &str) -> bool {
913 matches!(
914 flag,
915 "--name"
916 | "--since"
917 | "--user"
918 | "--distinct-id"
919 | "--trace"
920 | "--trace-id"
921 | "--level"
922 | "--severity"
923 | "--search"
924 | "--project"
925 | "--project-id"
926 | "--release"
927 | "--environment"
928 | "--env"
929 | "--status"
930 | "--limit"
931 )
932}
933
934fn parse_trace_detail_or_explain(rest: &[String]) -> Result<Command, CliError> {
936 let (id, tail) = take_required_position(rest, "trace_id", "provide a trace id")?;
937 if let Some(command) =
938 parse_detail_explain_suffix(ExplainTarget::Trace(id.clone()), tail.as_slice())?
939 {
940 return Ok(command);
941 }
942 let target = ReadTarget::Trace(id);
943 let flags = parse_detail_read_flags(
944 tail.as_slice(),
945 "read trace",
946 READ_TRACE_NEXT_STEP,
947 TRACE_DETAIL_UNSUPPORTED_FLAGS,
948 )?;
949 let json = flags.is_json();
950 let options = flags.into_read_options();
951 validate_read_filters(&target, &options)?;
952
953 Ok(Command::Read {
954 target,
955 options: Box::new(options),
956 json,
957 })
958}
959
960fn parse_detail_explain_suffix(
962 target: ExplainTarget,
963 args: &[String],
964) -> Result<Option<Command>, CliError> {
965 let normalized = move_leading_json_to_tail(args);
966 if normalized.first().is_none_or(|arg| arg != "explain") {
967 return Ok(None);
968 }
969 Ok(Some(Command::Explain {
970 target,
971 json: parse_flags(&normalized[1..], FlagScope::Explain)?.is_json(),
972 }))
973}
974
975fn parse_detail_read_flags(
977 args: &[String],
978 command: &'static str,
979 next: &'static str,
980 unsupported_flags: &[&str],
981) -> Result<crate::flags::Flags, CliError> {
982 reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
983 parse_flags(args, FlagScope::Read)
984}
985
986fn parse_pasted_detail_id(id: &str, args: &[String]) -> Result<Command, CliError> {
988 if is_issue_id(id) && has_issue_status_action(args) {
989 return parse_issue_first_status_shortcut(id.to_owned(), args);
990 }
991 let explain_args = move_leading_json_to_tail(args);
992 if explain_args.first().is_some_and(|arg| arg == "explain") {
993 let target = infer_explain_target(id).ok_or_else(|| unknown_command(id))?;
994 return Ok(Command::Explain {
995 target,
996 json: parse_flags(&explain_args[1..], FlagScope::Explain)?.is_json(),
997 });
998 }
999 let (target, flags) = if is_trace_id(id) {
1000 (
1001 ReadTarget::Trace(id.to_owned()),
1002 parse_detail_read_flags(
1003 args,
1004 "read trace",
1005 READ_TRACE_NEXT_STEP,
1006 TRACE_DETAIL_UNSUPPORTED_FLAGS,
1007 )?,
1008 )
1009 } else if is_issue_id(id) {
1010 (
1011 ReadTarget::Issue(id.to_owned()),
1012 parse_detail_read_flags(
1013 args,
1014 "read issue",
1015 READ_ISSUE_NEXT_STEP,
1016 ISSUE_DETAIL_UNSUPPORTED_FLAGS,
1017 )?,
1018 )
1019 } else {
1020 return Err(unknown_command(id));
1021 };
1022 let json = flags.is_json();
1023 let options = flags.into_read_options();
1024 validate_read_filters(&target, &options)?;
1025
1026 Ok(Command::Read {
1027 target,
1028 options: Box::new(options),
1029 json,
1030 })
1031}
1032
1033fn validate_read_filters(target: &ReadTarget, filters: &ReadOptions) -> Result<(), CliError> {
1035 let unsupported = match target {
1036 ReadTarget::Logs => filters
1037 .first_log_unsupported_flag()
1038 .map(|flag| (flag, "read logs", READ_LOGS_NEXT_STEP)),
1039 ReadTarget::Issues => filters
1040 .first_issue_list_unsupported_flag()
1041 .map(|flag| (flag, "read issues", READ_ISSUES_NEXT_STEP)),
1042 ReadTarget::Actions => filters
1043 .first_action_unsupported_flag()
1044 .map(|flag| (flag, "read actions", READ_ACTIONS_NEXT_STEP)),
1045 ReadTarget::Releases => filters
1046 .first_release_unsupported_flag()
1047 .map(|flag| (flag, "read releases", READ_RELEASES_NEXT_STEP)),
1048 ReadTarget::Trace(_) => filters
1049 .first_trace_detail_unsupported_flag()
1050 .map(|flag| (flag, "read trace", READ_TRACE_NEXT_STEP)),
1051 ReadTarget::Issue(_) => filters
1052 .first_issue_detail_unsupported_flag()
1053 .map(|flag| (flag, "read issue", READ_ISSUE_NEXT_STEP)),
1054 };
1055
1056 if let Some((flag, command, next)) = unsupported {
1057 return Err(CliError::UnsupportedFlag {
1058 flag: flag.to_owned(),
1059 command,
1060 next,
1061 });
1062 }
1063 Ok(())
1064}
1065
1066fn parse_explain(args: &[String]) -> Result<Command, CliError> {
1068 let (resource, rest) = take_required_position(args, "resource", EXPLAIN_RESOURCE_NEXT_STEP)?;
1069 let (target, tail) = match resource.as_str() {
1070 "issue" => {
1071 let (id, tail) =
1072 take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1073 (ExplainTarget::Issue(id), tail)
1074 }
1075 "trace" => {
1076 let (id, tail) =
1077 take_required_position(rest.as_slice(), "trace_id", "provide a trace id")?;
1078 (ExplainTarget::Trace(id), tail)
1079 }
1080 other => {
1081 if let Some(target) = infer_explain_target(other) {
1082 (target, rest)
1083 } else {
1084 return Err(unknown_resource(other, EXPLAIN_RESOURCE_NEXT_STEP));
1085 }
1086 }
1087 };
1088 let flags = parse_flags(tail.as_slice(), FlagScope::Explain)?;
1089 Ok(Command::Explain {
1090 target,
1091 json: flags.is_json(),
1092 })
1093}
1094
1095fn parse_set(args: &[String]) -> Result<Command, CliError> {
1097 let (resource, rest) = take_required_position(args, "resource", SET_RESOURCE_NEXT_STEP)?;
1098 if resource != "issue" {
1099 return Err(unknown_resource(resource.as_str(), SET_RESOURCE_NEXT_STEP));
1100 }
1101 let (id, rest) = take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1102 let (status, tail) =
1103 take_required_position(rest.as_slice(), "status", ISSUE_STATUS_ARGUMENT_NEXT_STEP)?;
1104 let status = normalize_status(status.as_str())?;
1105 let flags = parse_flags(tail.as_slice(), FlagScope::Set)?;
1106
1107 Ok(Command::Set {
1108 target: SetTarget::IssueStatus { id, status },
1109 json: flags.is_json(),
1110 })
1111}