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