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