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, issues, actions, or omit a resource";
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 "account" if tail.first().is_some_and(|arg| arg == "usage") => {
161 parse_discovery_help(HelpTopic::Usage, &tail[1..])
162 }
163 alias if auth_namespace::is_namespace(alias) => auth_namespace::parse(tail),
164 alias if auth_namespace::is_help_alias(alias) => parse_help_alias(HelpTopic::Auth, tail),
165 "json" | "output" => parse_help_alias(HelpTopic::Json, tail),
166 alias if is_examples_help_alias(alias) => parse_help_alias(HelpTopic::Examples, tail),
167 alias if is_project_help_alias(alias) => parse_discovery_help(HelpTopic::Projects, tail),
168 "usage" => parse_discovery_help(HelpTopic::Usage, tail),
169 alias if is_direct_filter_help_alias(alias) => parse_help_alias(HelpTopic::Read, tail),
170 "read" => parse_read(tail),
171 alias if is_read_verb(alias) => parse_read_verb(alias, tail),
172 status if is_known_issue_status(status) && has_issue_id_candidate(tail) => {
173 parse_status_first_issue_id_shortcut(status, tail)
174 }
175 status
176 if is_known_issue_status(status) && has_status_first_issue_resource_candidate(tail) =>
177 {
178 parse_status_first_issue_read(status, tail)
179 }
180 status if is_known_issue_status(status) => parse_bare_issue_status_shortcut(status, tail),
181 alias if is_log_search_shortcut(alias) => {
182 parse_search_shortcut(log_search_shortcut_label(alias), tail)
183 }
184 "log" => parse_read_resource("logs", tail),
185 "release" => parse_read_resource("releases", tail),
186 alias if is_trace_term(alias) && !has_position_candidate(tail) => {
187 parse_help_alias(HelpTopic::ReadTrace, tail)
188 }
189 "logs" | "issues" | "errors" | "error" | "exceptions" | "exception" | "actions"
190 | "events" | "event" | "action" | "releases" | "trace" | "issue" => {
191 parse_read_resource(head, tail)
192 }
193 "traces" | "span" | "spans" if has_position_candidate(tail) => {
194 parse_read_resource("trace", tail)
195 }
196 "resolve" | "close" | "ignore" | "reopen" => parse_issue_status_shortcut(head, tail),
197 alias if is_watch_command_alias(alias) => parse_watch(tail),
198 "explain" => parse_explain(tail),
199 "set" => parse_set(tail),
200 id if is_pasted_detail_id(id) => parse_pasted_detail_id(id, tail),
201 _ => Err(unknown_command(head)),
202 }
203}
204
205fn parse_discovery_help(topic: HelpTopic, args: &[String]) -> Result<Command, CliError> {
207 validate_help_flags(args)?;
208 Ok(Command::Help {
209 topic,
210 json: args.iter().any(|arg| arg == "--json"),
211 })
212}
213
214fn parse_global_json(values: &[String], tail: &[String]) -> Result<Command, CliError> {
216 if global_json_tail_has_duplicate(tail) {
217 return Err(CliError::DuplicateFlag {
218 flag: "--json",
219 next: "use --json once",
220 });
221 }
222 if tail.is_empty() {
223 return Ok(Command::Help {
224 topic: HelpTopic::Root,
225 json: true,
226 });
227 }
228
229 let mut normalized = Vec::with_capacity(values.len());
230 if let Some(program) = values.first() {
231 normalized.push(program.clone());
232 }
233 normalized.extend(tail.iter().cloned());
234 normalized.push(String::from("--json"));
235 parse_values(normalized.as_slice())
236}
237
238fn global_json_tail_has_duplicate(tail: &[String]) -> bool {
240 if !tail.iter().any(|arg| arg == "--json") {
241 return false;
242 }
243 let Some((command, rest)) = tail.split_first() else {
244 return false;
245 };
246 let Some(separator_index) = literal_log_search_separator_index(command, rest) else {
247 return true;
248 };
249 rest[..separator_index].iter().any(|arg| arg == "--json")
250}
251fn unknown_resource(resource: &str, next: &'static str) -> CliError {
253 CliError::UnknownResource {
254 resource: resource.to_owned(),
255 next,
256 }
257}
258
259fn unknown_flag(flag: &str, next: &'static str) -> CliError {
261 CliError::UnknownFlag {
262 flag: flag.to_owned(),
263 next,
264 }
265}
266
267fn unknown_read_resource(resource: &str) -> CliError {
269 unknown_resource(resource, read_resource_next_step(resource))
270}
271
272fn read_resource_next_step(resource: &str) -> &'static str {
274 match resource {
275 "trace" | "traces" | "span" | "spans" => READ_TRACE_ALIAS_NEXT_STEP,
276 _ => READ_RESOURCE_NEXT_STEP,
277 }
278}
279
280fn unknown_command(command: &str) -> CliError {
282 CliError::UnknownCommandName {
283 command: command.to_owned(),
284 next: unknown_command_next_step(command),
285 }
286}
287
288fn unknown_command_next_step(command: &str) -> &'static str {
290 match command {
291 "logg" | "lgs" => "did you mean logbrew logs?",
292 "action" | "event" | "events" => "did you mean logbrew actions?",
293 "releaze" | "rels" => "did you mean logbrew releases?",
294 "statuz" | "stats" => "did you mean logbrew status?",
295 "error" | "errors" | "exception" | "exceptions" => "did you mean logbrew issues?",
296 "trace" | "traces" | "span" | "spans" => TRACE_COMMAND_NEXT_STEP,
297 "env" | "environment" | "environments" => {
298 "use --environment <environment> with logs, issues, actions, releases, or traces"
299 }
300 alias if auth_namespace::is_help_alias(alias) => "run logbrew help auth",
301 _ => HELP_NEXT_STEP,
302 }
303}
304
305fn is_status_help_alias(value: &str) -> bool {
307 matches!(value, "status" | "health" | "ping" | "doctor")
308}
309
310fn is_examples_help_alias(value: &str) -> bool {
312 matches!(
313 value,
314 "example" | "examples" | "sample" | "samples" | "recipe" | "recipes"
315 )
316}
317
318fn is_setup_alias(value: &str) -> bool {
320 matches!(value, "setup" | "init" | "install" | "configure" | "sdk")
321}
322
323fn is_project_help_alias(value: &str) -> bool {
325 matches!(value, "project" | "projects")
326}
327
328fn is_watch_command_alias(value: &str) -> bool {
330 matches!(value, "watch" | "tail" | "follow" | "stream")
331}
332
333fn is_trace_term(value: &str) -> bool {
335 matches!(value, "trace" | "traces" | "span" | "spans")
336}
337
338fn is_version_flag(value: &str) -> bool {
340 matches!(value, "--version" | "-V")
341}
342
343fn parse_login(args: &[String]) -> Result<Command, CliError> {
345 let flags = parse_flags(args, FlagScope::Login)?;
346 let json = flags.is_json();
347 Ok(Command::Login {
348 open_browser: flags.should_open_browser() && !json,
349 json,
350 })
351}
352
353fn parse_logout(args: &[String]) -> Result<Command, CliError> {
355 let flags = parse_flags(args, FlagScope::Logout)?;
356 Ok(Command::Logout {
357 json: flags.is_json(),
358 })
359}
360
361fn parse_setup(args: &[String]) -> Result<Command, CliError> {
363 let flags = parse_flags(args, FlagScope::Setup)?;
364 Ok(Command::Setup {
365 auto: flags.is_auto(),
366 yes: flags.skip_prompts(),
367 json: flags.is_json(),
368 })
369}
370
371fn parse_status(args: &[String]) -> Result<Command, CliError> {
373 let flags = parse_flags(args, FlagScope::Status)?;
374 Ok(Command::Status {
375 json: flags.is_json(),
376 })
377}
378
379fn parse_version(args: &[String]) -> Result<Command, CliError> {
381 let flags = parse_flags(args, FlagScope::Version)?;
382 Ok(Command::Version {
383 json: flags.is_json(),
384 })
385}
386
387fn take_required_arg<'a>(
389 args: &'a [String],
390 argument: &'static str,
391 next: &'static str,
392) -> Result<(&'a str, &'a [String]), CliError> {
393 let Some((value, rest)) = args.split_first() else {
394 return Err(CliError::MissingArgument { argument, next });
395 };
396 if value.starts_with('-') {
397 return Err(CliError::MissingArgument { argument, next });
398 }
399 Ok((value.as_str(), rest))
400}
401
402fn move_leading_json_to_tail(args: &[String]) -> Vec<String> {
404 if args.first().is_some_and(|arg| arg == "--json") {
405 let mut normalized = Vec::with_capacity(args.len());
406 normalized.extend(args[1..].iter().cloned());
407 normalized.push(String::from("--json"));
408 normalized
409 } else {
410 args.to_vec()
411 }
412}
413
414fn has_position_candidate(args: &[String]) -> bool {
416 move_leading_json_to_tail(args)
417 .first()
418 .is_some_and(|arg| !arg.starts_with('-'))
419}
420
421fn take_required_position(
423 args: &[String],
424 argument: &'static str,
425 next: &'static str,
426) -> Result<(String, Vec<String>), CliError> {
427 let normalized = move_leading_json_to_tail(args);
428 let (value, rest) = take_required_arg(normalized.as_slice(), argument, next)?;
429 Ok((value.to_owned(), rest.to_vec()))
430}
431
432fn parse_read(args: &[String]) -> Result<Command, CliError> {
434 let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
435 let resource = normalize_read_resource(resource.as_str());
436 if is_recency_read_verb(resource) {
437 return parse_read_verb(resource, rest.as_slice());
438 }
439 if is_known_issue_status(resource) && has_status_first_issue_resource_candidate(rest.as_slice())
440 {
441 return parse_status_first_issue_read(resource, rest.as_slice());
442 }
443 parse_read_resource(resource, rest.as_slice())
444}
445
446fn normalize_read_resource(resource: &str) -> &str {
448 match resource {
449 "log" => "logs",
450 "release" => "releases",
451 _ => resource,
452 }
453}
454
455fn parse_read_verb(verb: &str, args: &[String]) -> Result<Command, CliError> {
457 let rewritten_args = recency_count_shortcut_args(verb, args);
458 let args = rewritten_args.as_deref().unwrap_or(args);
459 let (resource, rest) = take_required_position(args, "resource", READ_RESOURCE_NEXT_STEP)?;
460 if is_known_issue_status(resource.as_str())
461 && has_status_first_issue_resource_candidate(rest.as_slice())
462 {
463 return parse_status_first_issue_read(resource.as_str(), rest.as_slice());
464 }
465 let resource = normalize_read_verb_resource(verb, resource.as_str());
466 parse_read_resource(resource, rest.as_slice())
467}
468
469fn recency_count_shortcut_args(verb: &str, args: &[String]) -> Option<Vec<String>> {
471 if !is_recency_read_verb(verb) {
472 return None;
473 }
474 let normalized = move_leading_json_to_tail(args);
475 let (count, tail) = normalized.split_first().filter(|(count, tail)| {
476 !tail.is_empty() && count.chars().all(|char| char.is_ascii_digit())
477 })?;
478 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
479 rewritten.push(tail[0].clone());
480 let rest = &tail[1..];
481 if let Some(separator_index) = rest.iter().position(|arg| arg == "--") {
482 rewritten.extend(rest[..separator_index].iter().cloned());
483 rewritten.push(String::from("--limit"));
484 rewritten.push(count.clone());
485 rewritten.extend(rest[separator_index..].iter().cloned());
486 return Some(rewritten);
487 }
488 rewritten.extend(rest.iter().cloned());
489 rewritten.push(String::from("--limit"));
490 rewritten.push(count.clone());
491 Some(rewritten)
492}
493
494fn is_read_verb(value: &str) -> bool {
496 matches!(value, "show" | "list" | "get") || is_recency_read_verb(value)
497}
498
499fn is_recency_read_verb(value: &str) -> bool {
501 matches!(value, "latest" | "recent" | "last" | "newest")
502}
503
504fn normalize_read_verb_resource<'a>(verb: &str, resource: &'a str) -> &'a str {
506 match (verb, resource) {
507 ("list" | "show" | "get", "log") => "logs",
508 (alias, "log") if is_recency_read_verb(alias) => "logs",
509 (alias, "issue") if is_recency_read_verb(alias) => "issues",
510 ("list", "issue") => "issues",
511 ("list" | "show" | "get", "release") => "releases",
512 (alias, "release") if is_recency_read_verb(alias) => "releases",
513 _ => resource,
514 }
515}
516
517fn is_log_search_shortcut(command: &str) -> bool {
519 matches!(command, "search" | "find" | "grep")
520}
521
522fn is_log_search_separator_literal(command: &str, args: &[String]) -> bool {
524 literal_log_search_separator_index(command, args).is_some()
525}
526
527fn log_search_shortcut_label(command: &str) -> &'static str {
529 match command {
530 "find" => "find",
531 "grep" => "grep",
532 _ => "search",
533 }
534}
535
536fn parse_search_shortcut(label: &'static str, args: &[String]) -> Result<Command, CliError> {
538 let (query, tail) = take_search_query(args, label)?;
539 let mut rest = Vec::with_capacity(tail.len() + 2);
540 if query.starts_with('-') {
541 rest.push(format!("--search={query}"));
542 } else {
543 rest.push(String::from("--search"));
544 rest.push(query);
545 }
546 rest.extend(tail);
547 parse_read_resource("logs", rest.as_slice())
548}
549
550fn take_search_query(
552 args: &[String],
553 argument: &'static str,
554) -> Result<(String, Vec<String>), CliError> {
555 let normalized = move_leading_json_to_tail(args);
556 if normalized.first().is_some_and(|arg| arg == "--") {
557 return take_separator_search_query(normalized.as_slice(), argument);
558 }
559 let query_word_count = normalized
560 .iter()
561 .take_while(|arg| !arg.starts_with('-'))
562 .count();
563 if query_word_count == 0 {
564 return Err(CliError::MissingArgument {
565 argument,
566 next: SEARCH_NEXT_STEP,
567 });
568 }
569 let query = normalized[..query_word_count].join(" ");
570 let tail = normalized[query_word_count..].to_vec();
571 Ok((query, tail))
572}
573
574fn take_separator_search_query(
576 args: &[String],
577 argument: &'static str,
578) -> Result<(String, Vec<String>), CliError> {
579 let words = &args[1..];
580 if words.is_empty() {
581 return Err(CliError::MissingArgument {
582 argument,
583 next: SEARCH_NEXT_STEP,
584 });
585 }
586 let has_trailing_json_mode = words.len() > 1 && words.last().is_some_and(|arg| arg == "--json");
587 let query_end = if has_trailing_json_mode {
588 words.len() - 1
589 } else {
590 words.len()
591 };
592 let query = words[..query_end].join(" ");
593 let tail = if has_trailing_json_mode {
594 vec![String::from("--json")]
595 } else {
596 Vec::new()
597 };
598 Ok((query, tail))
599}
600
601fn parse_read_resource(resource: &str, rest: &[String]) -> Result<Command, CliError> {
603 let (target, flags) = match resource {
604 "logs" => parse_log_list_read(rest)?,
605 alias if is_issue_collection_alias(alias) && has_issue_id_candidate(rest) => {
606 return parse_issue_detail_or_status(rest);
607 }
608 alias if is_issue_collection_alias(alias) => parse_issue_list_read(rest)?,
609 alias if is_action_collection_alias(alias) => parse_action_list_read(rest)?,
610 "releases" => parse_list_read(
611 ReadTarget::Releases,
612 rest,
613 "read releases",
614 READ_RELEASES_NEXT_STEP,
615 &[
616 "--name",
617 "--since",
618 "--user",
619 "--distinct-id",
620 "--trace",
621 "--trace-id",
622 "--level",
623 "--severity",
624 "--search",
625 "--status",
626 ],
627 )?,
628 "trace" => return parse_trace_detail_or_explain(rest),
629 "traces" | "span" | "spans" if has_position_candidate(rest) => {
630 return parse_trace_detail_or_explain(rest);
631 }
632 "issue" if has_issue_status_candidate(rest) => parse_issue_list_read(rest)?,
633 "issue" => return parse_issue_detail_or_status(rest),
634 other => return Err(unknown_read_resource(other)),
635 };
636 let json = flags.is_json();
637 let options = flags.into_read_options();
638 validate_read_filters(&target, &options)?;
639
640 Ok(Command::Read {
641 target,
642 options: Box::new(options),
643 json,
644 })
645}
646
647fn is_issue_collection_alias(value: &str) -> bool {
649 matches!(
650 value,
651 "issues" | "errors" | "error" | "exceptions" | "exception"
652 )
653}
654
655fn is_status_first_issue_collection_alias(value: &str) -> bool {
657 value == "issue" || is_issue_collection_alias(value)
658}
659
660fn has_status_first_issue_resource_candidate(args: &[String]) -> bool {
662 move_leading_json_to_tail(args)
663 .first()
664 .is_some_and(|arg| is_status_first_issue_collection_alias(arg))
665}
666
667fn has_issue_status_candidate(args: &[String]) -> bool {
669 move_leading_json_to_tail(args)
670 .first()
671 .is_some_and(|arg| is_known_issue_status(arg))
672}
673
674fn is_action_collection_alias(value: &str) -> bool {
676 matches!(value, "actions" | "events" | "event" | "action")
677}
678
679fn parse_log_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
681 let args = log_shortcut_args(rest);
682 parse_list_read(
683 ReadTarget::Logs,
684 args.as_slice(),
685 "read logs",
686 READ_LOGS_NEXT_STEP,
687 &["--name", "--user", "--distinct-id", "--status"],
688 )
689}
690
691fn parse_issue_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
693 let args = issue_status_shortcut_args(rest);
694 parse_list_read(
695 ReadTarget::Issues,
696 args.as_slice(),
697 "read issues",
698 READ_ISSUES_NEXT_STEP,
699 &[
700 "--name",
701 "--since",
702 "--user",
703 "--distinct-id",
704 "--trace",
705 "--trace-id",
706 "--level",
707 "--severity",
708 "--search",
709 ],
710 )
711}
712
713fn parse_status_first_issue_read(status: &str, args: &[String]) -> Result<Command, CliError> {
715 let canonical_status = normalize_status(status)?;
716 let (resource, rest) = take_required_position(args, "resource", READ_ISSUES_NEXT_STEP)?;
717 let resource = resource.as_str();
718 if !is_status_first_issue_collection_alias(resource) {
719 return Err(unknown_read_resource(resource));
720 }
721 let resource = if resource == "issue" {
722 "issues"
723 } else {
724 resource
725 };
726 let mut rewritten = Vec::with_capacity(rest.len() + 2);
727 rewritten.push(String::from("--status"));
728 rewritten.push(canonical_status);
729 rewritten.extend(rest);
730 parse_read_resource(resource, rewritten.as_slice())
731}
732
733fn issue_status_shortcut_args(args: &[String]) -> Vec<String> {
735 let normalized = move_leading_json_to_tail(args);
736 let Some((status, tail)) = normalized
737 .split_first()
738 .and_then(|(status, tail)| normalize_status(status).ok().map(|value| (value, tail)))
739 else {
740 return args.to_vec();
741 };
742 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
743 rewritten.push(String::from("--status"));
744 rewritten.push(status);
745 rewritten.extend(tail.iter().cloned());
746 rewritten
747}
748
749fn parse_action_list_read(rest: &[String]) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
751 let args = action_name_shortcut_args(rest);
752 parse_list_read(
753 ReadTarget::Actions,
754 args.as_slice(),
755 "read actions",
756 READ_ACTIONS_NEXT_STEP,
757 ACTION_LIST_UNSUPPORTED_FLAGS,
758 )
759}
760
761fn action_name_shortcut_args(args: &[String]) -> Vec<String> {
763 let normalized = move_leading_json_to_tail(args);
764 let Some((name, tail)) = normalized
765 .split_first()
766 .filter(|(name, _)| !name.starts_with('-') && !is_read_filter_word(name))
767 else {
768 return args.to_vec();
769 };
770 let mut rewritten = Vec::with_capacity(normalized.len() + 2);
771 rewritten.push(String::from("--name"));
772 rewritten.push(name.clone());
773 rewritten.extend(tail.iter().cloned());
774 rewritten
775}
776
777fn has_issue_id_candidate(args: &[String]) -> bool {
779 move_leading_json_to_tail(args)
780 .first()
781 .is_some_and(|arg| is_issue_id(arg))
782}
783
784fn parse_issue_detail_or_status(args: &[String]) -> Result<Command, CliError> {
786 let (id, tail) = take_required_position(args, "issue_id", "provide an issue id")?;
787 if has_issue_status_action(tail.as_slice()) {
788 return parse_issue_first_status_shortcut(id, tail.as_slice());
789 }
790 if let Some(command) =
791 parse_detail_explain_suffix(ExplainTarget::Issue(id.clone()), tail.as_slice())?
792 {
793 return Ok(command);
794 }
795 let target = ReadTarget::Issue(id);
796 let flags = parse_detail_read_flags(
797 tail.as_slice(),
798 "read issue",
799 READ_ISSUE_NEXT_STEP,
800 ISSUE_DETAIL_UNSUPPORTED_FLAGS,
801 )?;
802 let json = flags.is_json();
803 let options = flags.into_read_options();
804 validate_read_filters(&target, &options)?;
805
806 Ok(Command::Read {
807 target,
808 options: Box::new(options),
809 json,
810 })
811}
812
813fn parse_list_read(
815 target: ReadTarget,
816 args: &[String],
817 command: &'static str,
818 next: &'static str,
819 unsupported_flags: &[&str],
820) -> Result<(ReadTarget, crate::flags::Flags), CliError> {
821 reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
822 Ok((target, parse_flags(args, FlagScope::Read)?))
823}
824
825fn reject_unsupported_read_flags(
827 args: &[String],
828 command: &'static str,
829 next: &'static str,
830 unsupported_flags: &[&str],
831) -> Result<(), CliError> {
832 let mut index = 0;
833 let mut seen = Vec::new();
834 while let Some(arg) = args.get(index) {
835 let (flag, inline_value) = arg
836 .split_once('=')
837 .map_or((arg.as_str(), None), |(name, value)| (name, Some(value)));
838 if !is_read_value_flag(flag) {
839 if flag == "--json" && inline_value.is_none() {
840 if seen.contains(&"--json") {
841 return Ok(());
842 }
843 seen.push("--json");
844 index += 1;
845 continue;
846 }
847 if inline_value.is_some() && is_simple_flag(flag) {
848 return Err(CliError::UnsupportedFlag {
849 flag: arg.to_owned(),
850 command,
851 next,
852 });
853 }
854 if arg.starts_with('-') {
855 return Err(unknown_flag(arg, next));
856 }
857 return Ok(());
858 }
859 if unsupported_flags.contains(&flag) {
860 return Err(CliError::UnsupportedFlag {
861 flag: user_facing_read_flag(flag).to_owned(),
862 command,
863 next,
864 });
865 }
866 if let Some(canonical) = read_value_canonical_flag(flag) {
867 if seen.contains(&canonical) {
868 return Ok(());
869 }
870 seen.push(canonical);
871 }
872 if inline_value.is_some_and(str::is_empty) {
873 return Ok(());
874 }
875 if inline_value.is_some_and(|value| has_invalid_supported_read_value(flag, value)) {
876 return Ok(());
877 }
878 if inline_value.is_none() {
879 let Some(value) = args.get(index + 1) else {
880 return Ok(());
881 };
882 if value.starts_with('-') {
883 return Ok(());
884 }
885 if has_invalid_supported_read_value(flag, value) {
886 return Ok(());
887 }
888 index += 1;
889 }
890 index += 1;
891 }
892 Ok(())
893}
894
895fn read_value_canonical_flag(flag: &str) -> Option<&'static str> {
897 let canonical = match flag {
898 "--name" => "--name",
899 "--since" => "--since",
900 "--user" | "--distinct-id" => "--user",
901 "--trace" | "--trace-id" => "--trace",
902 "--level" | "--severity" => "--severity",
903 "--search" => "--search",
904 "--project" | "--project-id" => "--project",
905 "--release" => "--release",
906 "--environment" | "--env" => "--environment",
907 "--status" => "--status",
908 "--limit" => "--limit",
909 _ => return None,
910 };
911 Some(canonical)
912}
913
914fn user_facing_read_flag(flag: &str) -> &str {
916 match flag {
917 "--level" => "--severity",
918 other => other,
919 }
920}
921
922fn has_invalid_supported_read_value(flag: &str, value: &str) -> bool {
924 match flag {
925 "--level" | "--severity" => !is_known_log_level(value),
926 "--status" => !is_known_issue_status(value),
927 "--limit" => value.parse::<u32>().map_or(true, |limit| limit == 0),
928 _ => false,
929 }
930}
931
932fn is_known_log_level(value: &str) -> bool {
934 normalize_log_level(value).is_ok()
935}
936
937fn is_ambiguous_log_search_word(value: &str) -> bool {
939 is_read_filter_word(value) || value.contains('@') || is_trace_id(value)
940}
941
942fn is_known_issue_status(value: &str) -> bool {
944 normalize_status(value).is_ok()
945}
946
947fn is_read_value_flag(flag: &str) -> bool {
949 matches!(
950 flag,
951 "--name"
952 | "--since"
953 | "--user"
954 | "--distinct-id"
955 | "--trace"
956 | "--trace-id"
957 | "--level"
958 | "--severity"
959 | "--search"
960 | "--project"
961 | "--project-id"
962 | "--release"
963 | "--environment"
964 | "--env"
965 | "--status"
966 | "--limit"
967 )
968}
969
970fn parse_trace_detail_or_explain(rest: &[String]) -> Result<Command, CliError> {
972 let (id, tail) = take_required_position(rest, "trace_id", "provide a trace id")?;
973 if let Some(command) =
974 parse_detail_explain_suffix(ExplainTarget::Trace(id.clone()), tail.as_slice())?
975 {
976 return Ok(command);
977 }
978 let target = ReadTarget::Trace(id);
979 let flags = parse_detail_read_flags(
980 tail.as_slice(),
981 "read trace",
982 READ_TRACE_NEXT_STEP,
983 TRACE_DETAIL_UNSUPPORTED_FLAGS,
984 )?;
985 let json = flags.is_json();
986 let options = flags.into_read_options();
987 validate_read_filters(&target, &options)?;
988
989 Ok(Command::Read {
990 target,
991 options: Box::new(options),
992 json,
993 })
994}
995
996fn parse_detail_explain_suffix(
998 target: ExplainTarget,
999 args: &[String],
1000) -> Result<Option<Command>, CliError> {
1001 let normalized = move_leading_json_to_tail(args);
1002 if normalized.first().is_none_or(|arg| arg != "explain") {
1003 return Ok(None);
1004 }
1005 Ok(Some(Command::Explain {
1006 target,
1007 json: parse_flags(&normalized[1..], FlagScope::Explain)?.is_json(),
1008 }))
1009}
1010
1011fn parse_detail_read_flags(
1013 args: &[String],
1014 command: &'static str,
1015 next: &'static str,
1016 unsupported_flags: &[&str],
1017) -> Result<crate::flags::Flags, CliError> {
1018 reject_unsupported_read_flags(args, command, next, unsupported_flags)?;
1019 parse_flags(args, FlagScope::Read)
1020}
1021
1022fn parse_pasted_detail_id(id: &str, args: &[String]) -> Result<Command, CliError> {
1024 if is_issue_id(id) && has_issue_status_action(args) {
1025 return parse_issue_first_status_shortcut(id.to_owned(), args);
1026 }
1027 let explain_args = move_leading_json_to_tail(args);
1028 if explain_args.first().is_some_and(|arg| arg == "explain") {
1029 let target = infer_explain_target(id).ok_or_else(|| unknown_command(id))?;
1030 return Ok(Command::Explain {
1031 target,
1032 json: parse_flags(&explain_args[1..], FlagScope::Explain)?.is_json(),
1033 });
1034 }
1035 let (target, flags) = if is_trace_id(id) {
1036 (
1037 ReadTarget::Trace(id.to_owned()),
1038 parse_detail_read_flags(
1039 args,
1040 "read trace",
1041 READ_TRACE_NEXT_STEP,
1042 TRACE_DETAIL_UNSUPPORTED_FLAGS,
1043 )?,
1044 )
1045 } else if is_issue_id(id) {
1046 (
1047 ReadTarget::Issue(id.to_owned()),
1048 parse_detail_read_flags(
1049 args,
1050 "read issue",
1051 READ_ISSUE_NEXT_STEP,
1052 ISSUE_DETAIL_UNSUPPORTED_FLAGS,
1053 )?,
1054 )
1055 } else {
1056 return Err(unknown_command(id));
1057 };
1058 let json = flags.is_json();
1059 let options = flags.into_read_options();
1060 validate_read_filters(&target, &options)?;
1061
1062 Ok(Command::Read {
1063 target,
1064 options: Box::new(options),
1065 json,
1066 })
1067}
1068
1069fn validate_read_filters(target: &ReadTarget, filters: &ReadOptions) -> Result<(), CliError> {
1071 let unsupported = match target {
1072 ReadTarget::Logs => filters
1073 .first_log_unsupported_flag()
1074 .map(|flag| (flag, "read logs", READ_LOGS_NEXT_STEP)),
1075 ReadTarget::Issues => filters
1076 .first_issue_list_unsupported_flag()
1077 .map(|flag| (flag, "read issues", READ_ISSUES_NEXT_STEP)),
1078 ReadTarget::Actions => filters
1079 .first_action_unsupported_flag()
1080 .map(|flag| (flag, "read actions", READ_ACTIONS_NEXT_STEP)),
1081 ReadTarget::Releases => filters
1082 .first_release_unsupported_flag()
1083 .map(|flag| (flag, "read releases", READ_RELEASES_NEXT_STEP)),
1084 ReadTarget::Trace(_) => filters
1085 .first_trace_detail_unsupported_flag()
1086 .map(|flag| (flag, "read trace", READ_TRACE_NEXT_STEP)),
1087 ReadTarget::Issue(_) => filters
1088 .first_issue_detail_unsupported_flag()
1089 .map(|flag| (flag, "read issue", READ_ISSUE_NEXT_STEP)),
1090 };
1091
1092 if let Some((flag, command, next)) = unsupported {
1093 return Err(CliError::UnsupportedFlag {
1094 flag: flag.to_owned(),
1095 command,
1096 next,
1097 });
1098 }
1099 Ok(())
1100}
1101
1102fn parse_explain(args: &[String]) -> Result<Command, CliError> {
1104 let (resource, rest) = take_required_position(args, "resource", EXPLAIN_RESOURCE_NEXT_STEP)?;
1105 let (target, tail) = match resource.as_str() {
1106 "issue" => {
1107 let (id, tail) =
1108 take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1109 (ExplainTarget::Issue(id), tail)
1110 }
1111 "trace" => {
1112 let (id, tail) =
1113 take_required_position(rest.as_slice(), "trace_id", "provide a trace id")?;
1114 (ExplainTarget::Trace(id), tail)
1115 }
1116 other => {
1117 if let Some(target) = infer_explain_target(other) {
1118 (target, rest)
1119 } else {
1120 return Err(unknown_resource(other, EXPLAIN_RESOURCE_NEXT_STEP));
1121 }
1122 }
1123 };
1124 let flags = parse_flags(tail.as_slice(), FlagScope::Explain)?;
1125 Ok(Command::Explain {
1126 target,
1127 json: flags.is_json(),
1128 })
1129}
1130
1131fn parse_set(args: &[String]) -> Result<Command, CliError> {
1133 let (resource, rest) = take_required_position(args, "resource", SET_RESOURCE_NEXT_STEP)?;
1134 if resource != "issue" {
1135 return Err(unknown_resource(resource.as_str(), SET_RESOURCE_NEXT_STEP));
1136 }
1137 let (id, rest) = take_required_position(rest.as_slice(), "issue_id", "provide an issue id")?;
1138 let (status, tail) =
1139 take_required_position(rest.as_slice(), "status", ISSUE_STATUS_ARGUMENT_NEXT_STEP)?;
1140 let status = normalize_status(status.as_str())?;
1141 let flags = parse_flags(tail.as_slice(), FlagScope::Set)?;
1142
1143 Ok(Command::Set {
1144 target: SetTarget::IssueStatus { id, status },
1145 json: flags.is_json(),
1146 })
1147}