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
40pub(crate) const ISSUE_STATUS_VALUES_NEXT_STEP: &str =
42 "use one of unresolved/open, resolved/closed, ignored";
43pub(crate) const ISSUE_STATUS_FILTER_NEXT_STEP: &str =
45 "use --status unresolved/open, --status resolved/closed, or --status ignored";
46pub(crate) const ISSUE_STATUS_ARGUMENT_NEXT_STEP: &str =
48 "provide one of unresolved/open, resolved/closed, ignored";
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum Command {
53 Help {
55 topic: HelpTopic,
57 json: bool,
59 },
60 Login {
62 open_browser: bool,
64 json: bool,
66 },
67 Logout {
69 json: bool,
71 },
72 Setup {
74 auto: bool,
76 yes: bool,
78 json: bool,
80 },
81 Status {
83 json: bool,
85 },
86 Version {
88 json: bool,
90 },
91 Read {
93 target: ReadTarget,
95 options: Box<ReadOptions>,
97 json: bool,
99 },
100 Watch {
102 target: WatchTarget,
104 options: WatchOptions,
106 json: bool,
108 },
109 Explain {
111 target: ExplainTarget,
113 json: bool,
115 },
116 Set {
118 target: SetTarget,
120 json: bool,
122 },
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum HelpTopic {
128 Root,
130 Login,
132 Logout,
134 Setup,
136 Status,
138 Version,
140 Auth,
142 Json,
144 Read,
146 ReadLogs,
148 ReadIssues,
150 ReadActions,
152 ReadReleases,
154 ReadTrace,
156 ReadIssue,
158 Watch,
160 Explain,
162 Set,
164}
165
166impl HelpTopic {
167 #[must_use]
169 pub const fn key(self) -> &'static str {
170 match self {
171 Self::Root => "root",
172 Self::Login => "login",
173 Self::Logout => "logout",
174 Self::Setup => "setup",
175 Self::Status => "status",
176 Self::Version => "version",
177 Self::Auth => "auth",
178 Self::Json => "json",
179 Self::Read => "read",
180 Self::ReadLogs => "read_logs",
181 Self::ReadIssues => "read_issues",
182 Self::ReadActions => "read_actions",
183 Self::ReadReleases => "read_releases",
184 Self::ReadTrace => "read_trace",
185 Self::ReadIssue => "read_issue",
186 Self::Watch => "watch",
187 Self::Explain => "explain",
188 Self::Set => "set",
189 }
190 }
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
195pub enum ReadTarget {
196 Logs,
198 Issues,
200 Actions,
202 Releases,
204 Trace(String),
206 Issue(String),
208}
209
210#[derive(Debug, Clone, Default, PartialEq, Eq)]
212pub struct ReadOptions {
213 pub name: Option<String>,
215 pub since: Option<String>,
217 pub user: Option<String>,
219 pub trace: Option<String>,
221 pub level: Option<String>,
223 pub search: Option<String>,
225 pub project: Option<String>,
227 pub release: Option<String>,
229 pub environment: Option<String>,
231 pub status: Option<String>,
233 pub limit: Option<String>,
235}
236
237impl ReadOptions {
238 #[must_use]
240 pub(crate) fn first_trace_detail_unsupported_flag(&self) -> Option<&'static str> {
241 first_present_flag([
242 (self.name.is_some(), "--name"),
243 (self.since.is_some(), "--since"),
244 (self.user.is_some(), "--user"),
245 (self.trace.is_some(), "--trace"),
246 (self.level.is_some(), "--severity"),
247 (self.search.is_some(), "--search"),
248 (self.status.is_some(), "--status"),
249 (self.limit.is_some(), "--limit"),
250 ])
251 }
252
253 #[must_use]
255 pub(crate) fn first_issue_detail_unsupported_flag(&self) -> Option<&'static str> {
256 first_present_flag([
257 (self.name.is_some(), "--name"),
258 (self.since.is_some(), "--since"),
259 (self.user.is_some(), "--user"),
260 (self.trace.is_some(), "--trace"),
261 (self.level.is_some(), "--severity"),
262 (self.search.is_some(), "--search"),
263 (self.project.is_some(), "--project"),
264 (self.release.is_some(), "--release"),
265 (self.environment.is_some(), "--environment"),
266 (self.status.is_some(), "--status"),
267 (self.limit.is_some(), "--limit"),
268 ])
269 }
270
271 #[must_use]
273 pub(crate) fn first_log_unsupported_flag(&self) -> Option<&'static str> {
274 first_present_flag([
275 (self.name.is_some(), "--name"),
276 (self.user.is_some(), "--user"),
277 (self.status.is_some(), "--status"),
278 ])
279 }
280
281 #[must_use]
283 pub(crate) fn first_issue_list_unsupported_flag(&self) -> Option<&'static str> {
284 first_present_flag([
285 (self.name.is_some(), "--name"),
286 (self.since.is_some(), "--since"),
287 (self.user.is_some(), "--user"),
288 (self.trace.is_some(), "--trace"),
289 (self.level.is_some(), "--severity"),
290 (self.search.is_some(), "--search"),
291 ])
292 }
293
294 #[must_use]
296 pub(crate) fn first_action_unsupported_flag(&self) -> Option<&'static str> {
297 first_present_flag([
298 (self.trace.is_some(), "--trace"),
299 (self.level.is_some(), "--severity"),
300 (self.search.is_some(), "--search"),
301 (self.status.is_some(), "--status"),
302 ])
303 }
304
305 #[must_use]
307 pub(crate) fn first_release_unsupported_flag(&self) -> Option<&'static str> {
308 first_present_flag([
309 (self.name.is_some(), "--name"),
310 (self.since.is_some(), "--since"),
311 (self.user.is_some(), "--user"),
312 (self.trace.is_some(), "--trace"),
313 (self.level.is_some(), "--severity"),
314 (self.search.is_some(), "--search"),
315 (self.status.is_some(), "--status"),
316 ])
317 }
318}
319
320fn first_present_flag<const N: usize>(flags: [(bool, &'static str); N]) -> Option<&'static str> {
322 flags
323 .iter()
324 .find_map(|(present, flag)| present.then_some(*flag))
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329pub enum WatchTarget {
330 All,
332 Logs,
334 Issues,
336 Actions,
338}
339
340#[derive(Debug, Clone, Default, PartialEq, Eq)]
342pub struct WatchOptions {
343 pub severity: Vec<String>,
345}
346
347#[derive(Debug, Clone, PartialEq, Eq)]
349pub enum ExplainTarget {
350 Issue(String),
352 Trace(String),
354}
355
356#[derive(Debug, Clone, PartialEq, Eq)]
358pub enum SetTarget {
359 IssueStatus {
361 id: String,
363 status: String,
365 },
366}
367
368#[derive(Debug, Clone, PartialEq, Eq)]
370pub struct CliEnvironment {
371 pub base_url: String,
373 pub token: Option<String>,
375 pub home: Option<std::path::PathBuf>,
377 pub cwd: Option<std::path::PathBuf>,
379}
380
381impl CliEnvironment {
382 #[must_use]
384 pub fn from_process() -> Self {
385 Self {
386 base_url: std::env::var("LOGBREW_API_URL")
387 .unwrap_or_else(|_| String::from("https://api.logbrew.co")),
388 token: std::env::var("LOGBREW_TOKEN").ok(),
389 home: std::env::var_os("HOME").map(std::path::PathBuf::from),
390 cwd: std::env::current_dir().ok(),
391 }
392 }
393}
394
395impl Command {
396 #[must_use]
398 pub fn http_path(&self) -> Option<String> {
399 match self {
400 Self::Read {
401 target, options, ..
402 } => Some(read_path(
403 target,
404 &ReadPathFilters {
405 name: options.name.as_deref(),
406 since: options.since.as_deref(),
407 user: options.user.as_deref(),
408 trace: options.trace.as_deref(),
409 level: options.level.as_deref(),
410 search: options.search.as_deref(),
411 project: options.project.as_deref(),
412 release: options.release.as_deref(),
413 environment: options.environment.as_deref(),
414 status: options.status.as_deref(),
415 limit: options.limit.as_deref(),
416 },
417 )),
418 Self::Explain { target, .. } => Some(explain_path(target)),
419 Self::Set { target, .. } => Some(set_path(target)),
420 Self::Help { .. }
421 | Self::Login { .. }
422 | Self::Logout { .. }
423 | Self::Setup { .. }
424 | Self::Status { .. }
425 | Self::Version { .. }
426 | Self::Watch { .. } => None,
427 }
428 }
429
430 #[must_use]
432 pub const fn wants_json(&self) -> bool {
433 match self {
434 Self::Help { json, .. }
435 | Self::Login { json, .. }
436 | Self::Logout { json }
437 | Self::Status { json }
438 | Self::Version { json }
439 | Self::Read { json, .. }
440 | Self::Watch { json, .. }
441 | Self::Explain { json, .. }
442 | Self::Set { json, .. }
443 | Self::Setup { json, .. } => *json,
444 }
445 }
446
447 #[must_use]
449 pub const fn http_method(&self) -> Option<HttpMethod> {
450 match self {
451 Self::Read { .. } | Self::Explain { .. } => Some(HttpMethod::Get),
452 Self::Set { .. } => Some(HttpMethod::Patch),
453 Self::Help { .. }
454 | Self::Login { .. }
455 | Self::Logout { .. }
456 | Self::Setup { .. }
457 | Self::Status { .. }
458 | Self::Version { .. }
459 | Self::Watch { .. } => None,
460 }
461 }
462
463 #[must_use]
465 pub fn request_body(&self) -> Option<serde_json::Value> {
466 match self {
467 Self::Set {
468 target: SetTarget::IssueStatus { status, .. },
469 ..
470 } => Some(serde_json::json!({ "status": status })),
471 Self::Help { .. }
472 | Self::Login { .. }
473 | Self::Logout { .. }
474 | Self::Setup { .. }
475 | Self::Status { .. }
476 | Self::Version { .. }
477 | Self::Read { .. }
478 | Self::Watch { .. }
479 | Self::Explain { .. } => None,
480 }
481 }
482}
483
484#[derive(Debug, Clone, Copy, PartialEq, Eq)]
486pub enum HttpMethod {
487 Get,
489 Patch,
491}
492
493pub async fn execute_command<W: std::io::Write>(
499 command: &Command,
500 env: &CliEnvironment,
501 output: &mut W,
502) -> Result<(), RuntimeError> {
503 match command {
504 Command::Help { topic, json } => execute_help(*topic, *json, output),
505 Command::Login { open_browser, json } => execute_login(env, *open_browser, *json, output),
506 Command::Logout { json } => execute_logout(env, *json, output),
507 Command::Setup { auto, yes, json } => execute_setup(env, *auto, *yes, *json, output),
508 Command::Status { json } => execute_status(env, *json, output).await,
509 Command::Version { json } => execute_version(*json, output),
510 Command::Read { .. } | Command::Explain { .. } | Command::Set { .. } => {
511 execute_http(command, env, output).await
512 }
513 Command::Watch {
514 target,
515 options,
516 json,
517 } => execute_watch(env, *target, options, *json, output).await,
518 }
519}
520
521fn execute_help<W: std::io::Write>(
523 topic: HelpTopic,
524 json: bool,
525 output: &mut W,
526) -> Result<(), RuntimeError> {
527 let help = help::help_text(topic);
528 if json {
529 let body = serde_json::json!({
530 "ok": true,
531 "topic": topic.key(),
532 "help": help,
533 });
534 writeln!(output, "{body}")?;
535 } else {
536 writeln!(output, "{help}")?;
537 }
538 Ok(())
539}
540
541fn execute_login<W: std::io::Write>(
543 env: &CliEnvironment,
544 should_open_browser: bool,
545 json: bool,
546 output: &mut W,
547) -> Result<(), RuntimeError> {
548 let auth_url = format!("{}/api/auth/cli/login", env.base_url.trim_end_matches('/'));
549 let opened = should_open_browser && open_browser(auth_url.as_str());
550
551 if json {
552 let body = serde_json::json!({
553 "ok": true,
554 "auth_url": auth_url,
555 "browser_opened": opened,
556 "next": "open auth_url in a browser",
557 });
558 writeln!(output, "{body}")?;
559 } else {
560 writeln!(output, "Open this URL to log in: {auth_url}")?;
561 writeln!(
562 output,
563 "Browser: {}",
564 if opened { "opened" } else { "not opened" }
565 )?;
566 writeln!(output, "Next: open the URL in a browser")?;
567 }
568 Ok(())
569}
570
571fn execute_logout<W: std::io::Write>(
573 env: &CliEnvironment,
574 json: bool,
575 output: &mut W,
576) -> Result<(), RuntimeError> {
577 write_logout_result(env, json, output)?;
578 Ok(())
579}
580
581fn execute_setup<W: std::io::Write>(
583 env: &CliEnvironment,
584 auto: bool,
585 yes: bool,
586 json: bool,
587 output: &mut W,
588) -> Result<(), RuntimeError> {
589 write_setup_plan(env.cwd.as_deref(), auto, yes, json, output)?;
590 Ok(())
591}
592
593async fn execute_http<W: std::io::Write>(
595 command: &Command,
596 env: &CliEnvironment,
597 output: &mut W,
598) -> Result<(), RuntimeError> {
599 let path = command.http_path().ok_or(CliError::UnknownCommand)?;
600 let url = format!("{}{}", env.base_url.trim_end_matches('/'), path);
601 let client = reqwest::Client::builder()
602 .timeout(std::time::Duration::from_secs(30))
603 .connect_timeout(std::time::Duration::from_secs(10))
604 .build()?;
605
606 let mut request = match command.http_method().unwrap_or(HttpMethod::Get) {
607 HttpMethod::Get => client.get(url),
608 HttpMethod::Patch => client.patch(url),
609 };
610
611 let credential = resolve_credential(env)?;
612 request = request.bearer_auth(credential.token);
613
614 if let Some(body) = command.request_body() {
615 request = request.json(&body);
616 }
617
618 let response = request.send().await?;
619 let status = response.status();
620 let body = response.text().await?;
621
622 if !status.is_success() {
623 return Err(RuntimeError::Api {
624 status: status.as_u16(),
625 body,
626 auth_source: credential.source,
627 auth_label: credential.label,
628 });
629 }
630
631 write_api_success(command, body.as_str(), output)?;
632 Ok(())
633}
634
635async fn execute_watch<W: std::io::Write>(
637 env: &CliEnvironment,
638 target: WatchTarget,
639 options: &WatchOptions,
640 json: bool,
641 output: &mut W,
642) -> Result<(), RuntimeError> {
643 if !json {
644 return Err(RuntimeError::Unavailable {
645 message: "watch streams JSON for agents",
646 next: "run logbrew watch --json",
647 });
648 }
649
650 let credential = resolve_credential(env)?;
651 let ticket = request_feed_ticket(env, &credential).await?;
652 let live_url = feed_live_url(env.base_url.as_str(), ticket.as_str())?;
653 let (mut websocket, _) = connect_async(live_url.as_str())
654 .await
655 .map_err(map_websocket_connect_error)?;
656
657 while let Some(message) = websocket.next().await {
658 let message = message.map_err(map_websocket_stream_error)?;
659 match message {
660 Message::Text(text) => {
661 let event = parse_live_event(text.as_str())?;
662 if watch_event_matches(target, options, &event) {
663 writeln!(output, "{event}")?;
664 }
665 }
666 Message::Binary(_) | Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => {}
667 Message::Close(_) => break,
668 }
669 }
670 Ok(())
671}
672
673async fn request_feed_ticket(
675 env: &CliEnvironment,
676 credential: &auth::AuthCredential,
677) -> Result<String, RuntimeError> {
678 let url = format!("{}/api/feed/ticket", env.base_url.trim_end_matches('/'));
679 let client = reqwest::Client::builder()
680 .timeout(std::time::Duration::from_secs(30))
681 .connect_timeout(std::time::Duration::from_secs(10))
682 .build()?;
683 let response = client
684 .post(url)
685 .bearer_auth(credential.token.as_str())
686 .send()
687 .await?;
688 let status = response.status();
689 let body = response.text().await?;
690 if !status.is_success() {
691 return Err(RuntimeError::Api {
692 status: status.as_u16(),
693 body,
694 auth_source: credential.source,
695 auth_label: credential.label,
696 });
697 }
698
699 let value = serde_json::from_str::<serde_json::Value>(body.as_str()).map_err(|_| {
700 RuntimeError::Unavailable {
701 message: "feed ticket response was not valid JSON",
702 next: "retry logbrew watch or run logbrew status",
703 }
704 })?;
705 value
706 .get("ticket")
707 .and_then(serde_json::Value::as_str)
708 .map(str::trim)
709 .filter(|ticket| !ticket.is_empty())
710 .map(ToOwned::to_owned)
711 .ok_or(RuntimeError::Unavailable {
712 message: "feed ticket response did not include a ticket",
713 next: "retry logbrew watch or run logbrew status",
714 })
715}
716
717fn feed_live_url(base_url: &str, ticket: &str) -> Result<String, RuntimeError> {
719 let trimmed = base_url.trim_end_matches('/');
720 let (scheme, rest) = websocket_base_parts(trimmed).ok_or(RuntimeError::Unavailable {
721 message: "LOGBREW_API_URL must start with http:// or https://",
722 next: "check LOGBREW_API_URL or run logbrew status",
723 })?;
724 Ok(format!(
725 "{scheme}://{rest}/api/feed/live?ticket={}",
726 encode_component(ticket)
727 ))
728}
729
730fn websocket_base_parts(base_url: &str) -> Option<(&'static str, &str)> {
732 base_url
733 .strip_prefix("https://")
734 .map(|rest| ("wss", rest))
735 .or_else(|| base_url.strip_prefix("http://").map(|rest| ("ws", rest)))
736}
737
738fn parse_live_event(text: &str) -> Result<serde_json::Value, RuntimeError> {
740 serde_json::from_str::<serde_json::Value>(text).map_err(|_| RuntimeError::Unavailable {
741 message: "live watch event was not valid JSON",
742 next: "retry logbrew watch or check LOGBREW_API_URL",
743 })
744}
745
746fn watch_event_matches(
748 target: WatchTarget,
749 options: &WatchOptions,
750 event: &serde_json::Value,
751) -> bool {
752 target_matches_event(target, event) && severity_matches(options, event)
753}
754
755fn target_matches_event(target: WatchTarget, event: &serde_json::Value) -> bool {
757 let event_type = event
758 .get("type")
759 .and_then(serde_json::Value::as_str)
760 .unwrap_or_default();
761 match target {
762 WatchTarget::All => true,
763 WatchTarget::Logs => event_type == "native_log",
764 WatchTarget::Issues => event_type == "native_issue",
765 WatchTarget::Actions => event_type == "native_action",
766 }
767}
768
769fn severity_matches(options: &WatchOptions, event: &serde_json::Value) -> bool {
771 if options.severity.is_empty() {
772 return true;
773 }
774 let Some(severity) = event
775 .get("data")
776 .and_then(|data| data.get("severity").or_else(|| data.get("level")))
777 .and_then(serde_json::Value::as_str)
778 else {
779 return false;
780 };
781 options
782 .severity
783 .iter()
784 .any(|allowed| allowed.as_str() == severity)
785}
786
787fn map_websocket_connect_error(error: WebSocketError) -> RuntimeError {
789 match error {
790 WebSocketError::Http(response) if response.status().as_u16() == 401 => {
791 RuntimeError::Unavailable {
792 message: "live watch ticket was rejected",
793 next: "run logbrew login",
794 }
795 }
796 WebSocketError::Http(_) => RuntimeError::Unavailable {
797 message: "live watch websocket upgrade failed",
798 next: "retry logbrew watch or check LOGBREW_API_URL",
799 },
800 WebSocketError::ConnectionClosed
801 | WebSocketError::AlreadyClosed
802 | WebSocketError::Io(_)
803 | WebSocketError::Tls(_)
804 | WebSocketError::Capacity(_)
805 | WebSocketError::Protocol(_)
806 | WebSocketError::WriteBufferFull(_)
807 | WebSocketError::Utf8(_)
808 | WebSocketError::AttackAttempt
809 | WebSocketError::Url(_)
810 | WebSocketError::HttpFormat(_) => RuntimeError::Unavailable {
811 message: "live watch websocket failed",
812 next: "retry logbrew watch or check LOGBREW_API_URL",
813 },
814 }
815}
816
817fn map_websocket_stream_error(error: WebSocketError) -> RuntimeError {
819 match error {
820 WebSocketError::ConnectionClosed | WebSocketError::AlreadyClosed => {
821 RuntimeError::Unavailable {
822 message: "live watch websocket closed",
823 next: "retry logbrew watch",
824 }
825 }
826 WebSocketError::Http(response) if response.status().as_u16() == 401 => {
827 RuntimeError::Unavailable {
828 message: "live watch ticket was rejected",
829 next: "run logbrew login",
830 }
831 }
832 WebSocketError::Http(_)
833 | WebSocketError::Io(_)
834 | WebSocketError::Tls(_)
835 | WebSocketError::Capacity(_)
836 | WebSocketError::Protocol(_)
837 | WebSocketError::WriteBufferFull(_)
838 | WebSocketError::Utf8(_)
839 | WebSocketError::AttackAttempt
840 | WebSocketError::Url(_)
841 | WebSocketError::HttpFormat(_) => RuntimeError::Unavailable {
842 message: "live watch websocket failed",
843 next: "retry logbrew watch or check LOGBREW_API_URL",
844 },
845 }
846}
847
848struct ReadPathFilters<'a> {
850 name: Option<&'a str>,
852 since: Option<&'a str>,
854 user: Option<&'a str>,
856 trace: Option<&'a str>,
858 level: Option<&'a str>,
860 search: Option<&'a str>,
862 project: Option<&'a str>,
864 release: Option<&'a str>,
866 environment: Option<&'a str>,
868 status: Option<&'a str>,
870 limit: Option<&'a str>,
872}
873
874fn read_path(target: &ReadTarget, filters: &ReadPathFilters<'_>) -> String {
876 match target {
877 ReadTarget::Logs => path_with_query(
878 "/api/logs",
879 &[
880 ("severity", filters.level),
881 ("search", filters.search),
882 ("since", filters.since),
883 ("trace_id", filters.trace),
884 ("project_id", filters.project),
885 ("release", filters.release),
886 ("environment", filters.environment),
887 ("limit", filters.limit),
888 ],
889 ),
890 ReadTarget::Issues => path_with_query(
891 "/api/telemetry/issues",
892 &[
893 ("status", filters.status),
894 ("project_id", filters.project),
895 ("release", filters.release),
896 ("environment", filters.environment),
897 ("limit", filters.limit),
898 ],
899 ),
900 ReadTarget::Actions => path_with_query(
901 "/api/telemetry/actions",
902 &[
903 ("name", filters.name),
904 ("since", filters.since),
905 ("distinct_id", filters.user),
906 ("project_id", filters.project),
907 ("release", filters.release),
908 ("environment", filters.environment),
909 ("limit", filters.limit),
910 ],
911 ),
912 ReadTarget::Releases => path_with_query(
913 "/api/telemetry/releases",
914 &[
915 ("project_id", filters.project),
916 ("release", filters.release),
917 ("environment", filters.environment),
918 ("limit", filters.limit),
919 ],
920 ),
921 ReadTarget::Trace(id) => path_with_query(
922 &format!("/api/telemetry/traces/{}", encode_component(id)),
923 &[
924 ("project_id", filters.project),
925 ("release", filters.release),
926 ("environment", filters.environment),
927 ],
928 ),
929 ReadTarget::Issue(id) => format!("/api/telemetry/issues/{}", encode_component(id)),
930 }
931}
932
933fn explain_path(target: &ExplainTarget) -> String {
935 match target {
936 ExplainTarget::Issue(id) => format!("/api/telemetry/issues/{}", encode_component(id)),
937 ExplainTarget::Trace(id) => format!("/api/telemetry/traces/{}", encode_component(id)),
938 }
939}
940
941fn set_path(target: &SetTarget) -> String {
943 match target {
944 SetTarget::IssueStatus { id, .. } => {
945 format!("/api/telemetry/issues/{}", encode_component(id))
946 }
947 }
948}
949
950fn path_with_query(path: &str, params: &[(&str, Option<&str>)]) -> String {
952 let query = params
953 .iter()
954 .filter_map(|(name, value)| value.map(|v| format!("{name}={}", encode_component(v))))
955 .collect::<Vec<_>>();
956
957 if query.is_empty() {
958 path.to_owned()
959 } else {
960 format!("{path}?{}", query.join("&"))
961 }
962}
963
964fn encode_component(value: &str) -> String {
966 let mut encoded = String::new();
967 for byte in value.bytes() {
968 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
969 encoded.push(char::from(byte));
970 } else {
971 encoded.push('%');
972 encoded.push(hex_digit(byte >> 4));
973 encoded.push(hex_digit(byte & 0x0f));
974 }
975 }
976 encoded
977}
978
979fn hex_digit(nibble: u8) -> char {
981 match nibble {
982 0..=9 => char::from(b'0' + nibble),
983 10..=15 => char::from(b'A' + (nibble - 10)),
984 _ => '?',
985 }
986}