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