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};
31use futures_util::StreamExt as _;
32pub use parser::parse_command;
33use render::write_api_success;
34use setup::write_setup_plan;
35use status::execute_status;
36use tokio_tungstenite::connect_async;
37use tokio_tungstenite::tungstenite::{Error as WebSocketError, Message};
38use version::execute_version;
39
40const WATCH_RECONNECT_INITIAL_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
42const WATCH_RECONNECT_MAX_DELAY: std::time::Duration = std::time::Duration::from_secs(30);
44const WATCH_RECONNECT_JITTER_MAX_MILLIS: u64 = 250;
46
47pub(crate) const ISSUE_STATUS_VALUES_NEXT_STEP: &str =
49 "use one of unresolved/open, resolved/closed, ignored";
50pub(crate) const ISSUE_STATUS_FILTER_NEXT_STEP: &str =
52 "use --status unresolved/open, --status resolved/closed, or --status ignored";
53pub(crate) const ISSUE_STATUS_ARGUMENT_NEXT_STEP: &str =
55 "provide one of unresolved/open, resolved/closed, ignored";
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum Command {
60 Help {
62 topic: HelpTopic,
64 json: bool,
66 },
67 Login {
69 open_browser: bool,
71 json: bool,
73 },
74 Logout {
76 json: bool,
78 },
79 Setup {
81 auto: bool,
83 yes: bool,
85 json: bool,
87 },
88 Status {
90 json: bool,
92 },
93 Version {
95 json: bool,
97 },
98 Read {
100 target: ReadTarget,
102 options: Box<ReadOptions>,
104 json: bool,
106 },
107 Watch {
109 target: WatchTarget,
111 options: WatchOptions,
113 json: bool,
115 },
116 Explain {
118 target: ExplainTarget,
120 json: bool,
122 },
123 Set {
125 target: SetTarget,
127 json: bool,
129 },
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum HelpTopic {
135 Root,
137 Login,
139 Logout,
141 Setup,
143 Status,
145 Version,
147 Auth,
149 Json,
151 Read,
153 ReadLogs,
155 ReadIssues,
157 ReadActions,
159 ReadReleases,
161 ReadTrace,
163 ReadIssue,
165 Watch,
167 Explain,
169 Set,
171}
172
173impl HelpTopic {
174 #[must_use]
176 pub const fn key(self) -> &'static str {
177 match self {
178 Self::Root => "root",
179 Self::Login => "login",
180 Self::Logout => "logout",
181 Self::Setup => "setup",
182 Self::Status => "status",
183 Self::Version => "version",
184 Self::Auth => "auth",
185 Self::Json => "json",
186 Self::Read => "read",
187 Self::ReadLogs => "read_logs",
188 Self::ReadIssues => "read_issues",
189 Self::ReadActions => "read_actions",
190 Self::ReadReleases => "read_releases",
191 Self::ReadTrace => "read_trace",
192 Self::ReadIssue => "read_issue",
193 Self::Watch => "watch",
194 Self::Explain => "explain",
195 Self::Set => "set",
196 }
197 }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
202pub enum ReadTarget {
203 Logs,
205 Issues,
207 Actions,
209 Releases,
211 Trace(String),
213 Issue(String),
215}
216
217#[derive(Debug, Clone, Default, PartialEq, Eq)]
219pub struct ReadOptions {
220 pub name: Option<String>,
222 pub since: Option<String>,
224 pub user: Option<String>,
226 pub trace: Option<String>,
228 pub level: Option<String>,
230 pub search: Option<String>,
232 pub project: Option<String>,
234 pub release: Option<String>,
236 pub environment: Option<String>,
238 pub status: Option<String>,
240 pub limit: Option<String>,
242}
243
244impl ReadOptions {
245 #[must_use]
247 pub(crate) fn first_trace_detail_unsupported_flag(&self) -> Option<&'static str> {
248 first_present_flag([
249 (self.name.is_some(), "--name"),
250 (self.since.is_some(), "--since"),
251 (self.user.is_some(), "--user"),
252 (self.trace.is_some(), "--trace"),
253 (self.level.is_some(), "--severity"),
254 (self.search.is_some(), "--search"),
255 (self.status.is_some(), "--status"),
256 (self.limit.is_some(), "--limit"),
257 ])
258 }
259
260 #[must_use]
262 pub(crate) fn first_issue_detail_unsupported_flag(&self) -> Option<&'static str> {
263 first_present_flag([
264 (self.name.is_some(), "--name"),
265 (self.since.is_some(), "--since"),
266 (self.user.is_some(), "--user"),
267 (self.trace.is_some(), "--trace"),
268 (self.level.is_some(), "--severity"),
269 (self.search.is_some(), "--search"),
270 (self.project.is_some(), "--project"),
271 (self.release.is_some(), "--release"),
272 (self.environment.is_some(), "--environment"),
273 (self.status.is_some(), "--status"),
274 (self.limit.is_some(), "--limit"),
275 ])
276 }
277
278 #[must_use]
280 pub(crate) fn first_log_unsupported_flag(&self) -> Option<&'static str> {
281 first_present_flag([
282 (self.name.is_some(), "--name"),
283 (self.user.is_some(), "--user"),
284 (self.status.is_some(), "--status"),
285 ])
286 }
287
288 #[must_use]
290 pub(crate) fn first_issue_list_unsupported_flag(&self) -> Option<&'static str> {
291 first_present_flag([
292 (self.name.is_some(), "--name"),
293 (self.since.is_some(), "--since"),
294 (self.user.is_some(), "--user"),
295 (self.trace.is_some(), "--trace"),
296 (self.level.is_some(), "--severity"),
297 (self.search.is_some(), "--search"),
298 ])
299 }
300
301 #[must_use]
303 pub(crate) fn first_action_unsupported_flag(&self) -> Option<&'static str> {
304 first_present_flag([
305 (self.trace.is_some(), "--trace"),
306 (self.level.is_some(), "--severity"),
307 (self.search.is_some(), "--search"),
308 (self.status.is_some(), "--status"),
309 ])
310 }
311
312 #[must_use]
314 pub(crate) fn first_release_unsupported_flag(&self) -> Option<&'static str> {
315 first_present_flag([
316 (self.name.is_some(), "--name"),
317 (self.since.is_some(), "--since"),
318 (self.user.is_some(), "--user"),
319 (self.trace.is_some(), "--trace"),
320 (self.level.is_some(), "--severity"),
321 (self.search.is_some(), "--search"),
322 (self.status.is_some(), "--status"),
323 ])
324 }
325}
326
327fn first_present_flag<const N: usize>(flags: [(bool, &'static str); N]) -> Option<&'static str> {
329 flags
330 .iter()
331 .find_map(|(present, flag)| present.then_some(*flag))
332}
333
334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336pub enum WatchTarget {
337 All,
339 Logs,
341 Issues,
343 Actions,
345}
346
347#[derive(Debug, Clone, Default, PartialEq, Eq)]
349pub struct WatchOptions {
350 pub severity: Vec<String>,
352}
353
354#[derive(Debug, Clone, PartialEq, Eq)]
356pub enum ExplainTarget {
357 Issue(String),
359 Trace(String),
361}
362
363#[derive(Debug, Clone, PartialEq, Eq)]
365pub enum SetTarget {
366 IssueStatus {
368 id: String,
370 status: String,
372 },
373}
374
375#[derive(Debug, Clone, PartialEq, Eq)]
377pub struct CliEnvironment {
378 pub base_url: String,
380 pub token: Option<String>,
382 pub home: Option<std::path::PathBuf>,
384 pub cwd: Option<std::path::PathBuf>,
386}
387
388impl CliEnvironment {
389 #[must_use]
391 pub fn from_process() -> Self {
392 Self {
393 base_url: std::env::var("LOGBREW_API_URL")
394 .unwrap_or_else(|_| String::from("https://api.logbrew.co")),
395 token: std::env::var("LOGBREW_TOKEN").ok(),
396 home: std::env::var_os("HOME").map(std::path::PathBuf::from),
397 cwd: std::env::current_dir().ok(),
398 }
399 }
400}
401
402impl Command {
403 #[must_use]
405 pub fn http_path(&self) -> Option<String> {
406 match self {
407 Self::Read {
408 target, options, ..
409 } => Some(read_path(
410 target,
411 &ReadPathFilters {
412 name: options.name.as_deref(),
413 since: options.since.as_deref(),
414 user: options.user.as_deref(),
415 trace: options.trace.as_deref(),
416 level: options.level.as_deref(),
417 search: options.search.as_deref(),
418 project: options.project.as_deref(),
419 release: options.release.as_deref(),
420 environment: options.environment.as_deref(),
421 status: options.status.as_deref(),
422 limit: options.limit.as_deref(),
423 },
424 )),
425 Self::Explain { target, .. } => Some(explain_path(target)),
426 Self::Set { target, .. } => Some(set_path(target)),
427 Self::Help { .. }
428 | Self::Login { .. }
429 | Self::Logout { .. }
430 | Self::Setup { .. }
431 | Self::Status { .. }
432 | Self::Version { .. }
433 | Self::Watch { .. } => None,
434 }
435 }
436
437 #[must_use]
439 pub const fn wants_json(&self) -> bool {
440 match self {
441 Self::Help { json, .. }
442 | Self::Login { json, .. }
443 | Self::Logout { json }
444 | Self::Status { json }
445 | Self::Version { json }
446 | Self::Read { json, .. }
447 | Self::Watch { json, .. }
448 | Self::Explain { json, .. }
449 | Self::Set { json, .. }
450 | Self::Setup { json, .. } => *json,
451 }
452 }
453
454 #[must_use]
456 pub const fn http_method(&self) -> Option<HttpMethod> {
457 match self {
458 Self::Read { .. } | Self::Explain { .. } => Some(HttpMethod::Get),
459 Self::Set { .. } => Some(HttpMethod::Patch),
460 Self::Help { .. }
461 | Self::Login { .. }
462 | Self::Logout { .. }
463 | Self::Setup { .. }
464 | Self::Status { .. }
465 | Self::Version { .. }
466 | Self::Watch { .. } => None,
467 }
468 }
469
470 #[must_use]
472 pub fn request_body(&self) -> Option<serde_json::Value> {
473 match self {
474 Self::Set {
475 target: SetTarget::IssueStatus { status, .. },
476 ..
477 } => Some(serde_json::json!({ "status": status })),
478 Self::Help { .. }
479 | Self::Login { .. }
480 | Self::Logout { .. }
481 | Self::Setup { .. }
482 | Self::Status { .. }
483 | Self::Version { .. }
484 | Self::Read { .. }
485 | Self::Watch { .. }
486 | Self::Explain { .. } => None,
487 }
488 }
489}
490
491#[derive(Debug, Clone, Copy, PartialEq, Eq)]
493pub enum HttpMethod {
494 Get,
496 Patch,
498}
499
500pub async fn execute_command<W: std::io::Write>(
506 command: &Command,
507 env: &CliEnvironment,
508 output: &mut W,
509) -> Result<(), RuntimeError> {
510 match command {
511 Command::Help { topic, json } => execute_help(*topic, *json, output),
512 Command::Login { open_browser, json } => execute_login(env, *open_browser, *json, output),
513 Command::Logout { json } => execute_logout(env, *json, output),
514 Command::Setup { auto, yes, json } => execute_setup(env, *auto, *yes, *json, output),
515 Command::Status { json } => execute_status(env, *json, output).await,
516 Command::Version { json } => execute_version(*json, output),
517 Command::Read { .. } | Command::Explain { .. } | Command::Set { .. } => {
518 execute_http(command, env, output).await
519 }
520 Command::Watch {
521 target,
522 options,
523 json,
524 } => execute_watch(env, *target, options, *json, output).await,
525 }
526}
527
528fn execute_help<W: std::io::Write>(
530 topic: HelpTopic,
531 json: bool,
532 output: &mut W,
533) -> Result<(), RuntimeError> {
534 let help = help::help_text(topic);
535 if json {
536 let body = serde_json::json!({
537 "ok": true,
538 "topic": topic.key(),
539 "help": help,
540 });
541 writeln!(output, "{body}")?;
542 } else {
543 writeln!(output, "{help}")?;
544 }
545 Ok(())
546}
547
548fn execute_login<W: std::io::Write>(
550 env: &CliEnvironment,
551 should_open_browser: bool,
552 json: bool,
553 output: &mut W,
554) -> Result<(), RuntimeError> {
555 let auth_url = format!("{}/api/auth/cli/login", env.base_url.trim_end_matches('/'));
556 let opened = should_open_browser && open_browser(auth_url.as_str());
557
558 if json {
559 let body = serde_json::json!({
560 "ok": true,
561 "auth_url": auth_url,
562 "browser_opened": opened,
563 "next": "open auth_url in a browser",
564 });
565 writeln!(output, "{body}")?;
566 } else {
567 writeln!(output, "Open this URL to log in: {auth_url}")?;
568 writeln!(
569 output,
570 "Browser: {}",
571 if opened { "opened" } else { "not opened" }
572 )?;
573 writeln!(output, "Next: open the URL in a browser")?;
574 }
575 Ok(())
576}
577
578fn execute_logout<W: std::io::Write>(
580 env: &CliEnvironment,
581 json: bool,
582 output: &mut W,
583) -> Result<(), RuntimeError> {
584 write_logout_result(env, json, output)?;
585 Ok(())
586}
587
588fn execute_setup<W: std::io::Write>(
590 env: &CliEnvironment,
591 auto: bool,
592 yes: bool,
593 json: bool,
594 output: &mut W,
595) -> Result<(), RuntimeError> {
596 write_setup_plan(env.cwd.as_deref(), auto, yes, json, output)?;
597 Ok(())
598}
599
600async fn execute_http<W: std::io::Write>(
602 command: &Command,
603 env: &CliEnvironment,
604 output: &mut W,
605) -> Result<(), RuntimeError> {
606 let path = command.http_path().ok_or(CliError::UnknownCommand)?;
607 let url = format!("{}{}", env.base_url.trim_end_matches('/'), path);
608 let client = reqwest::Client::builder()
609 .timeout(std::time::Duration::from_secs(30))
610 .connect_timeout(std::time::Duration::from_secs(10))
611 .build()?;
612
613 let mut request = match command.http_method().unwrap_or(HttpMethod::Get) {
614 HttpMethod::Get => client.get(url),
615 HttpMethod::Patch => client.patch(url),
616 };
617
618 let credential = resolve_credential(env)?;
619 request = request.bearer_auth(credential.token);
620
621 if let Some(body) = command.request_body() {
622 request = request.json(&body);
623 }
624
625 let response = request.send().await?;
626 let status = response.status();
627 let body = response.text().await?;
628
629 if !status.is_success() {
630 return Err(RuntimeError::Api {
631 status: status.as_u16(),
632 body,
633 auth_source: credential.source,
634 auth_label: credential.label,
635 });
636 }
637
638 write_api_success(command, body.as_str(), output)?;
639 Ok(())
640}
641
642async fn execute_watch<W: std::io::Write>(
644 env: &CliEnvironment,
645 target: WatchTarget,
646 options: &WatchOptions,
647 json: bool,
648 output: &mut W,
649) -> Result<(), RuntimeError> {
650 if !json {
651 return Err(RuntimeError::Unavailable {
652 message: "watch streams JSON for agents",
653 next: "run logbrew watch --json",
654 });
655 }
656
657 let credential = resolve_credential(env)?;
658 let mut reconnect_backoff = WatchReconnectBackoff::default();
659 loop {
660 let ticket = match request_feed_ticket(env, &credential).await {
661 Ok(ticket) => ticket,
662 Err(error) if reconnect_backoff.connected_once() && !runtime_error_is_auth(&error) => {
663 tokio::time::sleep(reconnect_backoff.next_delay()).await;
664 continue;
665 }
666 Err(error) => return Err(error),
667 };
668 let live_url = feed_live_url(env.base_url.as_str(), ticket.as_str())?;
669 let (mut websocket, _) = match connect_async(live_url.as_str()).await {
670 Ok(connection) => connection,
671 Err(error)
672 if reconnect_backoff.connected_once() && !websocket_error_is_auth(&error) =>
673 {
674 tokio::time::sleep(reconnect_backoff.next_delay()).await;
675 continue;
676 }
677 Err(error) => return Err(map_websocket_connect_error(error)),
678 };
679 reconnect_backoff.mark_connected();
680
681 let mut emitted_before_disconnect = false;
682 loop {
683 let Some(message) = websocket.next().await else {
684 break;
685 };
686 let message = match message {
687 Ok(message) => message,
688 Err(error) if websocket_error_is_auth(&error) => {
689 return Err(map_websocket_stream_error(error));
690 }
691 Err(_) => break,
692 };
693 match message {
694 Message::Text(text) => {
695 let event = parse_live_event(text.as_str())?;
696 if watch_event_matches(target, options, &event) {
697 writeln!(output, "{event}")?;
698 }
699 emitted_before_disconnect = true;
700 }
701 Message::Binary(_) | Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => {}
702 Message::Close(_) => return Ok(()),
703 }
704 }
705 if emitted_before_disconnect {
706 reconnect_backoff.reset();
707 }
708 tokio::time::sleep(reconnect_backoff.next_delay()).await;
709 }
710}
711
712#[derive(Debug, Default)]
714struct WatchReconnectBackoff {
715 connected_once: bool,
717 attempts: u32,
719}
720
721impl WatchReconnectBackoff {
722 const fn connected_once(&self) -> bool {
724 self.connected_once
725 }
726
727 const fn mark_connected(&mut self) {
729 self.connected_once = true;
730 }
731
732 const fn reset(&mut self) {
734 self.attempts = 0;
735 }
736
737 fn next_delay(&mut self) -> std::time::Duration {
739 let exponent = self.attempts.min(5);
740 let multiplier = 1_u64 << exponent;
741 self.attempts = self.attempts.saturating_add(1);
742 let base = WATCH_RECONNECT_INITIAL_DELAY
743 .as_secs()
744 .saturating_mul(multiplier)
745 .min(WATCH_RECONNECT_MAX_DELAY.as_secs());
746 let delay = std::time::Duration::from_secs(base) + watch_reconnect_jitter();
747 if delay > WATCH_RECONNECT_MAX_DELAY {
748 WATCH_RECONNECT_MAX_DELAY
749 } else {
750 delay
751 }
752 }
753}
754
755fn watch_reconnect_jitter() -> std::time::Duration {
757 let Ok(elapsed) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) else {
758 return std::time::Duration::ZERO;
759 };
760 std::time::Duration::from_millis(
761 u64::from(elapsed.subsec_millis()) % WATCH_RECONNECT_JITTER_MAX_MILLIS,
762 )
763}
764
765const fn runtime_error_is_auth(error: &RuntimeError) -> bool {
767 matches!(
768 error,
769 RuntimeError::MissingToken | RuntimeError::Api { status: 401, .. }
770 )
771}
772
773fn websocket_error_is_auth(error: &WebSocketError) -> bool {
775 matches!(error, WebSocketError::Http(response) if response.status().as_u16() == 401)
776}
777
778async fn request_feed_ticket(
780 env: &CliEnvironment,
781 credential: &auth::AuthCredential,
782) -> Result<String, RuntimeError> {
783 let url = format!("{}/api/feed/ticket", env.base_url.trim_end_matches('/'));
784 let client = reqwest::Client::builder()
785 .timeout(std::time::Duration::from_secs(30))
786 .connect_timeout(std::time::Duration::from_secs(10))
787 .build()?;
788 let response = client
789 .post(url)
790 .bearer_auth(credential.token.as_str())
791 .send()
792 .await?;
793 let status = response.status();
794 let body = response.text().await?;
795 if !status.is_success() {
796 return Err(RuntimeError::Api {
797 status: status.as_u16(),
798 body,
799 auth_source: credential.source,
800 auth_label: credential.label,
801 });
802 }
803
804 let value = serde_json::from_str::<serde_json::Value>(body.as_str()).map_err(|_| {
805 RuntimeError::Unavailable {
806 message: "feed ticket response was not valid JSON",
807 next: "retry logbrew watch or run logbrew status",
808 }
809 })?;
810 value
811 .get("ticket")
812 .and_then(serde_json::Value::as_str)
813 .map(str::trim)
814 .filter(|ticket| !ticket.is_empty())
815 .map(ToOwned::to_owned)
816 .ok_or(RuntimeError::Unavailable {
817 message: "feed ticket response did not include a ticket",
818 next: "retry logbrew watch or run logbrew status",
819 })
820}
821
822fn feed_live_url(base_url: &str, ticket: &str) -> Result<String, RuntimeError> {
824 let trimmed = base_url.trim_end_matches('/');
825 let (scheme, rest) = websocket_base_parts(trimmed).ok_or(RuntimeError::Unavailable {
826 message: "LOGBREW_API_URL must start with http:// or https://",
827 next: "check LOGBREW_API_URL or run logbrew status",
828 })?;
829 Ok(format!(
830 "{scheme}://{rest}/api/feed/live?ticket={}",
831 encode_component(ticket)
832 ))
833}
834
835fn websocket_base_parts(base_url: &str) -> Option<(&'static str, &str)> {
837 base_url
838 .strip_prefix("https://")
839 .map(|rest| ("wss", rest))
840 .or_else(|| base_url.strip_prefix("http://").map(|rest| ("ws", rest)))
841}
842
843fn parse_live_event(text: &str) -> Result<serde_json::Value, RuntimeError> {
845 serde_json::from_str::<serde_json::Value>(text).map_err(|_| RuntimeError::Unavailable {
846 message: "live watch event was not valid JSON",
847 next: "retry logbrew watch or check LOGBREW_API_URL",
848 })
849}
850
851fn watch_event_matches(
853 target: WatchTarget,
854 options: &WatchOptions,
855 event: &serde_json::Value,
856) -> bool {
857 target_matches_event(target, event) && severity_matches(options, event)
858}
859
860fn target_matches_event(target: WatchTarget, event: &serde_json::Value) -> bool {
862 let event_type = event
863 .get("type")
864 .and_then(serde_json::Value::as_str)
865 .unwrap_or_default();
866 match target {
867 WatchTarget::All => true,
868 WatchTarget::Logs => event_type == "native_log",
869 WatchTarget::Issues => event_type == "native_issue",
870 WatchTarget::Actions => event_type == "native_action",
871 }
872}
873
874fn severity_matches(options: &WatchOptions, event: &serde_json::Value) -> bool {
876 if options.severity.is_empty() {
877 return true;
878 }
879 let Some(severity) = event
880 .get("data")
881 .and_then(|data| data.get("severity").or_else(|| data.get("level")))
882 .and_then(serde_json::Value::as_str)
883 else {
884 return false;
885 };
886 options
887 .severity
888 .iter()
889 .any(|allowed| allowed.as_str() == severity)
890}
891
892fn map_websocket_connect_error(error: WebSocketError) -> RuntimeError {
894 match error {
895 WebSocketError::Http(response) if response.status().as_u16() == 401 => {
896 RuntimeError::Unavailable {
897 message: "live watch ticket was rejected",
898 next: "run logbrew login",
899 }
900 }
901 WebSocketError::Http(_) => RuntimeError::Unavailable {
902 message: "live watch websocket upgrade failed",
903 next: "retry logbrew watch or check LOGBREW_API_URL",
904 },
905 WebSocketError::ConnectionClosed
906 | WebSocketError::AlreadyClosed
907 | WebSocketError::Io(_)
908 | WebSocketError::Tls(_)
909 | WebSocketError::Capacity(_)
910 | WebSocketError::Protocol(_)
911 | WebSocketError::WriteBufferFull(_)
912 | WebSocketError::Utf8(_)
913 | WebSocketError::AttackAttempt
914 | WebSocketError::Url(_)
915 | WebSocketError::HttpFormat(_) => RuntimeError::Unavailable {
916 message: "live watch websocket failed",
917 next: "retry logbrew watch or check LOGBREW_API_URL",
918 },
919 }
920}
921
922fn map_websocket_stream_error(error: WebSocketError) -> RuntimeError {
924 match error {
925 WebSocketError::ConnectionClosed | WebSocketError::AlreadyClosed => {
926 RuntimeError::Unavailable {
927 message: "live watch websocket closed",
928 next: "retry logbrew watch",
929 }
930 }
931 WebSocketError::Http(response) if response.status().as_u16() == 401 => {
932 RuntimeError::Unavailable {
933 message: "live watch ticket was rejected",
934 next: "run logbrew login",
935 }
936 }
937 WebSocketError::Http(_)
938 | WebSocketError::Io(_)
939 | WebSocketError::Tls(_)
940 | WebSocketError::Capacity(_)
941 | WebSocketError::Protocol(_)
942 | WebSocketError::WriteBufferFull(_)
943 | WebSocketError::Utf8(_)
944 | WebSocketError::AttackAttempt
945 | WebSocketError::Url(_)
946 | WebSocketError::HttpFormat(_) => RuntimeError::Unavailable {
947 message: "live watch websocket failed",
948 next: "retry logbrew watch or check LOGBREW_API_URL",
949 },
950 }
951}
952
953struct ReadPathFilters<'a> {
955 name: Option<&'a str>,
957 since: Option<&'a str>,
959 user: Option<&'a str>,
961 trace: Option<&'a str>,
963 level: Option<&'a str>,
965 search: Option<&'a str>,
967 project: Option<&'a str>,
969 release: Option<&'a str>,
971 environment: Option<&'a str>,
973 status: Option<&'a str>,
975 limit: Option<&'a str>,
977}
978
979fn read_path(target: &ReadTarget, filters: &ReadPathFilters<'_>) -> String {
981 match target {
982 ReadTarget::Logs => path_with_query(
983 "/api/logs",
984 &[
985 ("severity", filters.level),
986 ("search", filters.search),
987 ("since", filters.since),
988 ("trace_id", filters.trace),
989 ("project_id", filters.project),
990 ("release", filters.release),
991 ("environment", filters.environment),
992 ("limit", filters.limit),
993 ],
994 ),
995 ReadTarget::Issues => path_with_query(
996 "/api/telemetry/issues",
997 &[
998 ("status", filters.status),
999 ("project_id", filters.project),
1000 ("release", filters.release),
1001 ("environment", filters.environment),
1002 ("limit", filters.limit),
1003 ],
1004 ),
1005 ReadTarget::Actions => path_with_query(
1006 "/api/telemetry/actions",
1007 &[
1008 ("name", filters.name),
1009 ("since", filters.since),
1010 ("distinct_id", filters.user),
1011 ("project_id", filters.project),
1012 ("release", filters.release),
1013 ("environment", filters.environment),
1014 ("limit", filters.limit),
1015 ],
1016 ),
1017 ReadTarget::Releases => path_with_query(
1018 "/api/telemetry/releases",
1019 &[
1020 ("project_id", filters.project),
1021 ("release", filters.release),
1022 ("environment", filters.environment),
1023 ("limit", filters.limit),
1024 ],
1025 ),
1026 ReadTarget::Trace(id) => path_with_query(
1027 &format!("/api/telemetry/traces/{}", encode_component(id)),
1028 &[
1029 ("project_id", filters.project),
1030 ("release", filters.release),
1031 ("environment", filters.environment),
1032 ],
1033 ),
1034 ReadTarget::Issue(id) => format!("/api/telemetry/issues/{}", encode_component(id)),
1035 }
1036}
1037
1038fn explain_path(target: &ExplainTarget) -> String {
1040 match target {
1041 ExplainTarget::Issue(id) => format!("/api/telemetry/issues/{}", encode_component(id)),
1042 ExplainTarget::Trace(id) => format!("/api/telemetry/traces/{}", encode_component(id)),
1043 }
1044}
1045
1046fn set_path(target: &SetTarget) -> String {
1048 match target {
1049 SetTarget::IssueStatus { id, .. } => {
1050 format!("/api/telemetry/issues/{}", encode_component(id))
1051 }
1052 }
1053}
1054
1055fn path_with_query(path: &str, params: &[(&str, Option<&str>)]) -> String {
1057 let query = params
1058 .iter()
1059 .filter_map(|(name, value)| value.map(|v| format!("{name}={}", encode_component(v))))
1060 .collect::<Vec<_>>();
1061
1062 if query.is_empty() {
1063 path.to_owned()
1064 } else {
1065 format!("{path}?{}", query.join("&"))
1066 }
1067}
1068
1069fn encode_component(value: &str) -> String {
1071 let mut encoded = String::new();
1072 for byte in value.bytes() {
1073 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
1074 encoded.push(char::from(byte));
1075 } else {
1076 encoded.push('%');
1077 encoded.push(hex_digit(byte >> 4));
1078 encoded.push(hex_digit(byte & 0x0f));
1079 }
1080 }
1081 encoded
1082}
1083
1084fn hex_digit(nibble: u8) -> char {
1086 match nibble {
1087 0..=9 => char::from(b'0' + nibble),
1088 10..=15 => char::from(b'A' + (nibble - 10)),
1089 _ => '?',
1090 }
1091}