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