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