1#![forbid(unsafe_code)]
8
9#[doc(hidden)]
10pub mod auth;
11#[doc(hidden)]
12pub mod auth_namespace;
13mod error;
14#[doc(hidden)]
15pub mod flags;
16pub mod help;
17#[doc(hidden)]
18pub mod ids;
19mod parser;
20#[doc(hidden)]
21pub mod render;
22#[doc(hidden)]
23pub mod setup;
24#[doc(hidden)]
25pub mod status;
26#[doc(hidden)]
27pub mod version;
28
29use auth::{open_browser, resolve_credential, write_logout_result};
30pub use error::{CliError, RuntimeError, write_cli_error, write_runtime_error};
31pub use parser::parse_command;
32use render::write_api_success;
33use setup::write_setup_plan;
34use status::execute_status;
35use version::execute_version;
36
37pub(crate) const ISSUE_STATUS_VALUES_NEXT_STEP: &str =
39 "use one of unresolved/open, resolved/closed, ignored";
40pub(crate) const ISSUE_STATUS_FILTER_NEXT_STEP: &str =
42 "use --status unresolved/open, --status resolved/closed, or --status ignored";
43pub(crate) const ISSUE_STATUS_ARGUMENT_NEXT_STEP: &str =
45 "provide one of unresolved/open, resolved/closed, ignored";
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum Command {
50 Help {
52 topic: HelpTopic,
54 json: bool,
56 },
57 Login {
59 open_browser: bool,
61 json: bool,
63 },
64 Logout {
66 json: bool,
68 },
69 Setup {
71 auto: bool,
73 yes: bool,
75 json: bool,
77 },
78 Status {
80 json: bool,
82 },
83 Version {
85 json: bool,
87 },
88 Read {
90 target: ReadTarget,
92 options: Box<ReadOptions>,
94 json: bool,
96 },
97 Watch {
99 target: WatchTarget,
101 json: bool,
103 },
104 Explain {
106 target: ExplainTarget,
108 json: bool,
110 },
111 Set {
113 target: SetTarget,
115 json: bool,
117 },
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum HelpTopic {
123 Root,
125 Login,
127 Logout,
129 Setup,
131 Status,
133 Version,
135 Auth,
137 Json,
139 Read,
141 ReadLogs,
143 ReadIssues,
145 ReadActions,
147 ReadReleases,
149 ReadTrace,
151 ReadIssue,
153 Watch,
155 Explain,
157 Set,
159}
160
161impl HelpTopic {
162 #[must_use]
164 pub const fn key(self) -> &'static str {
165 match self {
166 Self::Root => "root",
167 Self::Login => "login",
168 Self::Logout => "logout",
169 Self::Setup => "setup",
170 Self::Status => "status",
171 Self::Version => "version",
172 Self::Auth => "auth",
173 Self::Json => "json",
174 Self::Read => "read",
175 Self::ReadLogs => "read_logs",
176 Self::ReadIssues => "read_issues",
177 Self::ReadActions => "read_actions",
178 Self::ReadReleases => "read_releases",
179 Self::ReadTrace => "read_trace",
180 Self::ReadIssue => "read_issue",
181 Self::Watch => "watch",
182 Self::Explain => "explain",
183 Self::Set => "set",
184 }
185 }
186}
187
188#[derive(Debug, Clone, PartialEq, Eq)]
190pub enum ReadTarget {
191 Logs,
193 Issues,
195 Actions,
197 Releases,
199 Trace(String),
201 Issue(String),
203}
204
205#[derive(Debug, Clone, Default, PartialEq, Eq)]
207pub struct ReadOptions {
208 pub name: Option<String>,
210 pub since: Option<String>,
212 pub user: Option<String>,
214 pub trace: Option<String>,
216 pub level: Option<String>,
218 pub search: Option<String>,
220 pub project: Option<String>,
222 pub release: Option<String>,
224 pub environment: Option<String>,
226 pub status: Option<String>,
228 pub limit: Option<String>,
230}
231
232impl ReadOptions {
233 #[must_use]
235 pub(crate) fn first_trace_detail_unsupported_flag(&self) -> Option<&'static str> {
236 first_present_flag([
237 (self.name.is_some(), "--name"),
238 (self.since.is_some(), "--since"),
239 (self.user.is_some(), "--user"),
240 (self.trace.is_some(), "--trace"),
241 (self.level.is_some(), "--level"),
242 (self.search.is_some(), "--search"),
243 (self.status.is_some(), "--status"),
244 (self.limit.is_some(), "--limit"),
245 ])
246 }
247
248 #[must_use]
250 pub(crate) fn first_issue_detail_unsupported_flag(&self) -> Option<&'static str> {
251 first_present_flag([
252 (self.name.is_some(), "--name"),
253 (self.since.is_some(), "--since"),
254 (self.user.is_some(), "--user"),
255 (self.trace.is_some(), "--trace"),
256 (self.level.is_some(), "--level"),
257 (self.search.is_some(), "--search"),
258 (self.project.is_some(), "--project"),
259 (self.release.is_some(), "--release"),
260 (self.environment.is_some(), "--environment"),
261 (self.status.is_some(), "--status"),
262 (self.limit.is_some(), "--limit"),
263 ])
264 }
265
266 #[must_use]
268 pub(crate) fn first_log_unsupported_flag(&self) -> Option<&'static str> {
269 first_present_flag([
270 (self.name.is_some(), "--name"),
271 (self.user.is_some(), "--user"),
272 (self.status.is_some(), "--status"),
273 ])
274 }
275
276 #[must_use]
278 pub(crate) fn first_issue_list_unsupported_flag(&self) -> Option<&'static str> {
279 first_present_flag([
280 (self.name.is_some(), "--name"),
281 (self.since.is_some(), "--since"),
282 (self.user.is_some(), "--user"),
283 (self.trace.is_some(), "--trace"),
284 (self.level.is_some(), "--level"),
285 (self.search.is_some(), "--search"),
286 ])
287 }
288
289 #[must_use]
291 pub(crate) fn first_action_unsupported_flag(&self) -> Option<&'static str> {
292 first_present_flag([
293 (self.trace.is_some(), "--trace"),
294 (self.level.is_some(), "--level"),
295 (self.search.is_some(), "--search"),
296 (self.status.is_some(), "--status"),
297 ])
298 }
299
300 #[must_use]
302 pub(crate) fn first_release_unsupported_flag(&self) -> Option<&'static str> {
303 first_present_flag([
304 (self.name.is_some(), "--name"),
305 (self.since.is_some(), "--since"),
306 (self.user.is_some(), "--user"),
307 (self.trace.is_some(), "--trace"),
308 (self.level.is_some(), "--level"),
309 (self.search.is_some(), "--search"),
310 (self.status.is_some(), "--status"),
311 ])
312 }
313}
314
315fn first_present_flag<const N: usize>(flags: [(bool, &'static str); N]) -> Option<&'static str> {
317 flags
318 .iter()
319 .find_map(|(present, flag)| present.then_some(*flag))
320}
321
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub enum WatchTarget {
325 Logs,
327 Actions,
329}
330
331#[derive(Debug, Clone, PartialEq, Eq)]
333pub enum ExplainTarget {
334 Issue(String),
336 Trace(String),
338}
339
340#[derive(Debug, Clone, PartialEq, Eq)]
342pub enum SetTarget {
343 IssueStatus {
345 id: String,
347 status: String,
349 },
350}
351
352#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct CliEnvironment {
355 pub base_url: String,
357 pub token: Option<String>,
359 pub home: Option<std::path::PathBuf>,
361 pub cwd: Option<std::path::PathBuf>,
363}
364
365impl CliEnvironment {
366 #[must_use]
368 pub fn from_process() -> Self {
369 Self {
370 base_url: std::env::var("LOGBREW_API_URL")
371 .unwrap_or_else(|_| String::from("https://api.logbrew.co")),
372 token: std::env::var("LOGBREW_TOKEN").ok(),
373 home: std::env::var_os("HOME").map(std::path::PathBuf::from),
374 cwd: std::env::current_dir().ok(),
375 }
376 }
377}
378
379impl Command {
380 #[must_use]
382 pub fn http_path(&self) -> Option<String> {
383 match self {
384 Self::Read {
385 target, options, ..
386 } => Some(read_path(
387 target,
388 &ReadPathFilters {
389 name: options.name.as_deref(),
390 since: options.since.as_deref(),
391 user: options.user.as_deref(),
392 trace: options.trace.as_deref(),
393 level: options.level.as_deref(),
394 search: options.search.as_deref(),
395 project: options.project.as_deref(),
396 release: options.release.as_deref(),
397 environment: options.environment.as_deref(),
398 status: options.status.as_deref(),
399 limit: options.limit.as_deref(),
400 },
401 )),
402 Self::Explain { target, .. } => Some(explain_path(target)),
403 Self::Set { target, .. } => Some(set_path(target)),
404 Self::Help { .. }
405 | Self::Login { .. }
406 | Self::Logout { .. }
407 | Self::Setup { .. }
408 | Self::Status { .. }
409 | Self::Version { .. }
410 | Self::Watch { .. } => None,
411 }
412 }
413
414 #[must_use]
416 pub const fn wants_json(&self) -> bool {
417 match self {
418 Self::Help { json, .. }
419 | Self::Login { json, .. }
420 | Self::Logout { json }
421 | Self::Status { json }
422 | Self::Version { json }
423 | Self::Read { json, .. }
424 | Self::Watch { json, .. }
425 | Self::Explain { json, .. }
426 | Self::Set { json, .. }
427 | Self::Setup { json, .. } => *json,
428 }
429 }
430
431 #[must_use]
433 pub const fn http_method(&self) -> Option<HttpMethod> {
434 match self {
435 Self::Read { .. } | Self::Explain { .. } => Some(HttpMethod::Get),
436 Self::Set { .. } => Some(HttpMethod::Patch),
437 Self::Help { .. }
438 | Self::Login { .. }
439 | Self::Logout { .. }
440 | Self::Setup { .. }
441 | Self::Status { .. }
442 | Self::Version { .. }
443 | Self::Watch { .. } => None,
444 }
445 }
446
447 #[must_use]
449 pub fn request_body(&self) -> Option<serde_json::Value> {
450 match self {
451 Self::Set {
452 target: SetTarget::IssueStatus { status, .. },
453 ..
454 } => Some(serde_json::json!({ "status": status })),
455 Self::Help { .. }
456 | Self::Login { .. }
457 | Self::Logout { .. }
458 | Self::Setup { .. }
459 | Self::Status { .. }
460 | Self::Version { .. }
461 | Self::Read { .. }
462 | Self::Watch { .. }
463 | Self::Explain { .. } => None,
464 }
465 }
466}
467
468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
470pub enum HttpMethod {
471 Get,
473 Patch,
475}
476
477pub async fn execute_command<W: std::io::Write>(
483 command: &Command,
484 env: &CliEnvironment,
485 output: &mut W,
486) -> Result<(), RuntimeError> {
487 match command {
488 Command::Help { topic, json } => execute_help(*topic, *json, output),
489 Command::Login { open_browser, json } => execute_login(env, *open_browser, *json, output),
490 Command::Logout { json } => execute_logout(env, *json, output),
491 Command::Setup { auto, yes, json } => execute_setup(env, *auto, *yes, *json, output),
492 Command::Status { json } => execute_status(env, *json, output).await,
493 Command::Version { json } => execute_version(*json, output),
494 Command::Read { .. } | Command::Explain { .. } | Command::Set { .. } => {
495 execute_http(command, env, output).await
496 }
497 Command::Watch { target, .. } => execute_watch_placeholder(*target),
498 }
499}
500
501fn execute_help<W: std::io::Write>(
503 topic: HelpTopic,
504 json: bool,
505 output: &mut W,
506) -> Result<(), RuntimeError> {
507 let help = help::help_text(topic);
508 if json {
509 let body = serde_json::json!({
510 "ok": true,
511 "topic": topic.key(),
512 "help": help,
513 });
514 writeln!(output, "{body}")?;
515 } else {
516 writeln!(output, "{help}")?;
517 }
518 Ok(())
519}
520
521fn execute_login<W: std::io::Write>(
523 env: &CliEnvironment,
524 should_open_browser: bool,
525 json: bool,
526 output: &mut W,
527) -> Result<(), RuntimeError> {
528 let auth_url = format!("{}/api/auth/cli/login", env.base_url.trim_end_matches('/'));
529 let opened = should_open_browser && open_browser(auth_url.as_str());
530
531 if json {
532 let body = serde_json::json!({
533 "ok": true,
534 "auth_url": auth_url,
535 "browser_opened": opened,
536 "next": "open auth_url in a browser",
537 });
538 writeln!(output, "{body}")?;
539 } else {
540 writeln!(output, "Open this URL to log in: {auth_url}")?;
541 writeln!(
542 output,
543 "Browser: {}",
544 if opened { "opened" } else { "not opened" }
545 )?;
546 writeln!(output, "Next: open the URL in a browser")?;
547 }
548 Ok(())
549}
550
551fn execute_logout<W: std::io::Write>(
553 env: &CliEnvironment,
554 json: bool,
555 output: &mut W,
556) -> Result<(), RuntimeError> {
557 write_logout_result(env, json, output)?;
558 Ok(())
559}
560
561fn execute_setup<W: std::io::Write>(
563 env: &CliEnvironment,
564 auto: bool,
565 yes: bool,
566 json: bool,
567 output: &mut W,
568) -> Result<(), RuntimeError> {
569 write_setup_plan(env.cwd.as_deref(), auto, yes, json, output)?;
570 Ok(())
571}
572
573async fn execute_http<W: std::io::Write>(
575 command: &Command,
576 env: &CliEnvironment,
577 output: &mut W,
578) -> Result<(), RuntimeError> {
579 let path = command.http_path().ok_or(CliError::UnknownCommand)?;
580 let url = format!("{}{}", env.base_url.trim_end_matches('/'), path);
581 let client = reqwest::Client::builder()
582 .timeout(std::time::Duration::from_secs(30))
583 .connect_timeout(std::time::Duration::from_secs(10))
584 .build()?;
585
586 let mut request = match command.http_method().unwrap_or(HttpMethod::Get) {
587 HttpMethod::Get => client.get(url),
588 HttpMethod::Patch => client.patch(url),
589 };
590
591 let credential = resolve_credential(env)?;
592 request = request.bearer_auth(credential.token);
593
594 if let Some(body) = command.request_body() {
595 request = request.json(&body);
596 }
597
598 let response = request.send().await?;
599 let status = response.status();
600 let body = response.text().await?;
601
602 if !status.is_success() {
603 return Err(RuntimeError::Api {
604 status: status.as_u16(),
605 body,
606 auth_source: credential.source,
607 auth_label: credential.label,
608 });
609 }
610
611 write_api_success(command, body.as_str(), output)?;
612 Ok(())
613}
614
615const fn execute_watch_placeholder(target: WatchTarget) -> Result<(), RuntimeError> {
617 Err(RuntimeError::Unavailable {
618 message: "watch is reserved for the live stream transport",
619 next: watch_next_step(target),
620 })
621}
622
623const fn watch_next_step(target: WatchTarget) -> &'static str {
625 match target {
626 WatchTarget::Logs => "use logbrew logs for historical data until live watch is available",
627 WatchTarget::Actions => {
628 "use logbrew actions for historical data until live watch is available"
629 }
630 }
631}
632
633struct ReadPathFilters<'a> {
635 name: Option<&'a str>,
637 since: Option<&'a str>,
639 user: Option<&'a str>,
641 trace: Option<&'a str>,
643 level: Option<&'a str>,
645 search: Option<&'a str>,
647 project: Option<&'a str>,
649 release: Option<&'a str>,
651 environment: Option<&'a str>,
653 status: Option<&'a str>,
655 limit: Option<&'a str>,
657}
658
659fn read_path(target: &ReadTarget, filters: &ReadPathFilters<'_>) -> String {
661 match target {
662 ReadTarget::Logs => path_with_query(
663 "/api/logs",
664 &[
665 ("severity", filters.level),
666 ("search", filters.search),
667 ("since", filters.since),
668 ("trace_id", filters.trace),
669 ("project_id", filters.project),
670 ("release", filters.release),
671 ("environment", filters.environment),
672 ("limit", filters.limit),
673 ],
674 ),
675 ReadTarget::Issues => path_with_query(
676 "/api/telemetry/issues",
677 &[
678 ("status", filters.status),
679 ("project_id", filters.project),
680 ("release", filters.release),
681 ("environment", filters.environment),
682 ("limit", filters.limit),
683 ],
684 ),
685 ReadTarget::Actions => path_with_query(
686 "/api/telemetry/actions",
687 &[
688 ("name", filters.name),
689 ("since", filters.since),
690 ("distinct_id", filters.user),
691 ("project_id", filters.project),
692 ("release", filters.release),
693 ("environment", filters.environment),
694 ("limit", filters.limit),
695 ],
696 ),
697 ReadTarget::Releases => path_with_query(
698 "/api/telemetry/releases",
699 &[
700 ("project_id", filters.project),
701 ("release", filters.release),
702 ("environment", filters.environment),
703 ("limit", filters.limit),
704 ],
705 ),
706 ReadTarget::Trace(id) => path_with_query(
707 &format!("/api/telemetry/traces/{}", encode_component(id)),
708 &[
709 ("project_id", filters.project),
710 ("release", filters.release),
711 ("environment", filters.environment),
712 ],
713 ),
714 ReadTarget::Issue(id) => format!("/api/telemetry/issues/{}", encode_component(id)),
715 }
716}
717
718fn explain_path(target: &ExplainTarget) -> String {
720 match target {
721 ExplainTarget::Issue(id) => format!("/api/telemetry/issues/{}", encode_component(id)),
722 ExplainTarget::Trace(id) => format!("/api/telemetry/traces/{}", encode_component(id)),
723 }
724}
725
726fn set_path(target: &SetTarget) -> String {
728 match target {
729 SetTarget::IssueStatus { id, .. } => {
730 format!("/api/telemetry/issues/{}", encode_component(id))
731 }
732 }
733}
734
735fn path_with_query(path: &str, params: &[(&str, Option<&str>)]) -> String {
737 let query = params
738 .iter()
739 .filter_map(|(name, value)| value.map(|v| format!("{name}={}", encode_component(v))))
740 .collect::<Vec<_>>();
741
742 if query.is_empty() {
743 path.to_owned()
744 } else {
745 format!("{path}?{}", query.join("&"))
746 }
747}
748
749fn encode_component(value: &str) -> String {
751 let mut encoded = String::new();
752 for byte in value.bytes() {
753 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
754 encoded.push(char::from(byte));
755 } else {
756 encoded.push('%');
757 encoded.push(hex_digit(byte >> 4));
758 encoded.push(hex_digit(byte & 0x0f));
759 }
760 }
761 encoded
762}
763
764fn hex_digit(nibble: u8) -> char {
766 match nibble {
767 0..=9 => char::from(b'0' + nibble),
768 10..=15 => char::from(b'A' + (nibble - 10)),
769 _ => '?',
770 }
771}