Skip to main content

logbrew_cli/
lib.rs

1//! Native `LogBrew` command-line interface library.
2//!
3//! The CLI is intentionally small and predictable so coding agents can learn it
4//! quickly: `read`, `watch`, `explain`, and `set` cover data access and state
5//! changes, while `login`, `setup`, and `status` cover local configuration.
6
7#![forbid(unsafe_code)]
8
9#[doc(hidden)]
10pub mod auth;
11#[doc(hidden)]
12pub mod auth_namespace;
13#[doc(hidden)]
14pub mod doctor;
15mod error;
16#[doc(hidden)]
17pub mod flags;
18pub mod help;
19#[doc(hidden)]
20pub mod ids;
21#[doc(hidden)]
22pub mod investigate;
23mod native_debug_artifacts;
24mod parser;
25mod project_create;
26mod projects;
27#[doc(hidden)]
28pub mod render;
29#[doc(hidden)]
30pub mod setup;
31#[doc(hidden)]
32pub mod status;
33mod support;
34mod usage;
35#[doc(hidden)]
36pub mod version;
37
38use auth::{
39    AuthCredential, execute_login, execute_logout, send_authenticated_with_refresh,
40    token_is_project_ingest_key,
41};
42pub use error::{
43    CliError, RuntimeError, write_cli_error, write_native_debug_runtime_error, write_runtime_error,
44};
45use futures_util::StreamExt as _;
46pub use parser::parse_command;
47use render::write_api_success;
48use setup::write_setup_plan;
49use status::execute_status;
50use tokio_tungstenite::connect_async;
51use tokio_tungstenite::tungstenite::{Error as WebSocketError, Message};
52use version::execute_version;
53
54/// Initial delay before reconnecting a live watch stream.
55const WATCH_RECONNECT_INITIAL_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
56/// Maximum delay before reconnecting a live watch stream.
57const WATCH_RECONNECT_MAX_DELAY: std::time::Duration = std::time::Duration::from_secs(30);
58/// Maximum jitter added to reconnect delays.
59const WATCH_RECONNECT_JITTER_MAX_MILLIS: u64 = 250;
60
61/// Accepted issue status values for generic recovery text.
62pub(crate) const ISSUE_STATUS_VALUES_NEXT_STEP: &str =
63    "use one of unresolved/open, resolved/closed, ignored";
64/// Accepted issue status values for read filter recovery text.
65pub(crate) const ISSUE_STATUS_FILTER_NEXT_STEP: &str =
66    "use --status unresolved/open, --status resolved/closed, or --status ignored";
67/// Accepted issue status values for missing mutation arguments.
68pub(crate) const ISSUE_STATUS_ARGUMENT_NEXT_STEP: &str =
69    "provide one of unresolved/open, resolved/closed, ignored";
70
71/// Parsed `LogBrew` command.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum Command {
74    /// Shows command usage.
75    Help {
76        /// Help topic to display.
77        topic: HelpTopic,
78        /// Emit machine-readable JSON.
79        json: bool,
80    },
81    /// Opens browser-based authentication.
82    Login {
83        /// Try to open the login URL in the default browser.
84        open_browser: bool,
85        /// Emit machine-readable JSON.
86        json: bool,
87    },
88    /// Removes the local CLI token.
89    Logout {
90        /// Emit machine-readable JSON.
91        json: bool,
92    },
93    /// Detects the current project and prints a non-mutating SDK setup plan.
94    Setup {
95        /// Let the CLI pick the framework or runtime automatically.
96        auto: bool,
97        /// Suppress confirmation prompts.
98        yes: bool,
99        /// Emit machine-readable JSON.
100        json: bool,
101    },
102    /// Checks local auth and server reachability.
103    Status {
104        /// Emit machine-readable JSON.
105        json: bool,
106    },
107    /// Checks one project through a bounded read-only diagnostic sequence.
108    Doctor {
109        /// Account-owned project UUID.
110        project_id: String,
111        /// Emit machine-readable JSON.
112        json: bool,
113    },
114    /// Creates one project and securely persists its one-time ingest key.
115    ProjectCreate {
116        /// Normalized project creation fields and local persistence choice.
117        options: ProjectCreateOptions,
118        /// Emit machine-readable JSON.
119        json: bool,
120    },
121    /// Lists active account-owned projects.
122    Projects {
123        /// Emit the exact validated bare server array.
124        json: bool,
125    },
126    /// Reads authenticated account usage and configured limits.
127    Usage {
128        /// Emit the exact validated server object.
129        json: bool,
130    },
131    /// Prints the installed CLI version.
132    Version {
133        /// Emit machine-readable JSON.
134        json: bool,
135    },
136    /// Reads historical observability data.
137    Read {
138        /// Resource to read.
139        target: ReadTarget,
140        /// Read filters.
141        options: Box<ReadOptions>,
142        /// Emit machine-readable JSON.
143        json: bool,
144    },
145    /// Watches live observability data.
146    Watch {
147        /// Resource to watch.
148        target: WatchTarget,
149        /// Live watch filters applied client-side.
150        options: WatchOptions,
151        /// Emit machine-readable JSON.
152        json: bool,
153    },
154    /// Fetches context for an issue or trace so an agent can explain it.
155    Explain {
156        /// Resource to explain.
157        target: ExplainTarget,
158        /// Emit machine-readable JSON.
159        json: bool,
160    },
161    /// Follows the backend-directed, read-only investigation for one issue.
162    InvestigateIssue {
163        /// Grouped issue identifier.
164        issue_id: String,
165        /// Emit machine-readable JSON.
166        json: bool,
167    },
168    /// Uploads or verifies Apple native debug artifacts.
169    NativeDebugArtifacts {
170        /// Native debug-artifact operation.
171        target: NativeDebugArtifactsTarget,
172        /// Emit bounded machine-readable JSON.
173        json: bool,
174    },
175    /// Mutates server-side state.
176    Set {
177        /// Target state mutation.
178        target: SetTarget,
179        /// Emit machine-readable JSON.
180        json: bool,
181    },
182    /// Marks backend-owned project setup as seen.
183    ProjectSetupSeen {
184        /// Project identifier.
185        project_id: String,
186        /// Optional setup metadata sent to the backend.
187        options: ProjectSetupSeenOptions,
188        /// Emit machine-readable JSON.
189        json: bool,
190    },
191    /// Creates or reads account support tickets.
192    Support {
193        /// Support-ticket operation.
194        target: SupportTarget,
195        /// Emit machine-readable JSON.
196        json: bool,
197    },
198}
199
200/// Help topic for CLI usage output.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub enum HelpTopic {
203    /// Root command overview.
204    Root,
205    /// Browser login command.
206    Login,
207    /// Local logout command.
208    Logout,
209    /// SDK setup command.
210    Setup,
211    /// Status check command.
212    Status,
213    /// Installed CLI version command.
214    Version,
215    /// Authentication workflow overview.
216    Auth,
217    /// Machine-readable output overview.
218    Json,
219    /// First-run examples and common workflows.
220    Examples,
221    /// Backend-owned project setup and ingest key workflow overview.
222    Projects,
223    /// Backend-owned account usage and quota workflow overview.
224    Usage,
225    /// Read command overview.
226    Read,
227    /// Log reading command.
228    ReadLogs,
229    /// Issue reading command.
230    ReadIssues,
231    /// Action reading command.
232    ReadActions,
233    /// Release reading command.
234    ReadReleases,
235    /// Recent trace discovery command.
236    ReadTraces,
237    /// Trace reading command.
238    ReadTrace,
239    /// Single issue reading command.
240    ReadIssue,
241    /// Live watch command.
242    Watch,
243    /// Explain command.
244    Explain,
245    /// Server-directed issue investigation command.
246    Investigate,
247    /// Apple native debug-artifact upload and lookup commands.
248    NativeDebugArtifacts,
249    /// State mutation command.
250    Set,
251    /// Support-ticket workflow.
252    Support,
253}
254
255impl HelpTopic {
256    /// Returns a stable machine-readable topic name.
257    #[must_use]
258    pub const fn key(self) -> &'static str {
259        match self {
260            Self::Root => "root",
261            Self::Login => "login",
262            Self::Logout => "logout",
263            Self::Setup => "setup",
264            Self::Status => "status",
265            Self::Version => "version",
266            Self::Auth => "auth",
267            Self::Json => "json",
268            Self::Examples => "examples",
269            Self::Projects => "projects",
270            Self::Usage => "usage",
271            Self::Read => "read",
272            Self::ReadLogs => "read_logs",
273            Self::ReadIssues => "read_issues",
274            Self::ReadActions => "read_actions",
275            Self::ReadReleases => "read_releases",
276            Self::ReadTraces => "read_traces",
277            Self::ReadTrace => "read_trace",
278            Self::ReadIssue => "read_issue",
279            Self::Watch => "watch",
280            Self::Explain => "explain",
281            Self::Investigate => "investigate",
282            Self::NativeDebugArtifacts => "debug_artifacts",
283            Self::Set => "set",
284            Self::Support => "support",
285        }
286    }
287}
288
289/// Historical data target for `read`.
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub enum ReadTarget {
292    /// Structured logs.
293    Logs,
294    /// Grouped issues.
295    Issues,
296    /// Product actions.
297    Actions,
298    /// Release summaries.
299    Releases,
300    /// Recent trace summaries.
301    Traces,
302    /// One trace by ID.
303    Trace(String),
304    /// One issue by ID.
305    Issue(String),
306}
307
308/// Filters for historical read commands.
309#[derive(Debug, Clone, Default, PartialEq, Eq)]
310pub struct ReadOptions {
311    /// Optional action name filter.
312    pub name: Option<String>,
313    /// Optional service name filter.
314    pub service: Option<String>,
315    /// Optional relative or absolute lower time bound.
316    pub since: Option<String>,
317    /// Optional user or actor filter.
318    pub user: Option<String>,
319    /// Optional trace ID filter.
320    pub trace: Option<String>,
321    /// Optional log severity filter.
322    pub level: Option<String>,
323    /// Optional log message substring search.
324    pub search: Option<String>,
325    /// Optional project filter.
326    pub project: Option<String>,
327    /// Optional release filter.
328    pub release: Option<String>,
329    /// Optional environment filter.
330    pub environment: Option<String>,
331    /// Optional issue status filter.
332    pub status: Option<String>,
333    /// Optional row limit.
334    pub limit: Option<String>,
335    /// Optional minimum end-to-end trace duration in milliseconds.
336    pub min_duration_ms: Option<String>,
337    /// Optional pagination mode for endpoints with explicit page envelopes.
338    pub pagination: Option<String>,
339    /// Optional continuation timestamp.
340    pub cursor_time: Option<String>,
341    /// Optional continuation identifier.
342    pub cursor_id: Option<String>,
343}
344
345impl ReadOptions {
346    /// Returns the first filter that trace-detail reads cannot apply.
347    #[must_use]
348    pub(crate) fn first_trace_detail_unsupported_flag(&self) -> Option<&'static str> {
349        first_present_flag([
350            (self.name.is_some(), "--name"),
351            (self.service.is_some(), "--service"),
352            (self.since.is_some(), "--since"),
353            (self.user.is_some(), "--user"),
354            (self.trace.is_some(), "--trace"),
355            (self.level.is_some(), "--severity"),
356            (self.search.is_some(), "--search"),
357            (self.status.is_some(), "--status"),
358            (self.limit.is_some(), "--limit"),
359            (self.min_duration_ms.is_some(), "--min-duration-ms"),
360            (self.pagination.is_some(), "--pagination"),
361            (self.cursor_time.is_some(), "--cursor-time"),
362            (self.cursor_id.is_some(), "--cursor-id"),
363        ])
364    }
365
366    /// Returns the first filter that issue-detail reads cannot apply.
367    #[must_use]
368    pub(crate) fn first_issue_detail_unsupported_flag(&self) -> Option<&'static str> {
369        first_present_flag([
370            (self.name.is_some(), "--name"),
371            (self.service.is_some(), "--service"),
372            (self.since.is_some(), "--since"),
373            (self.user.is_some(), "--user"),
374            (self.trace.is_some(), "--trace"),
375            (self.level.is_some(), "--severity"),
376            (self.search.is_some(), "--search"),
377            (self.project.is_some(), "--project"),
378            (self.release.is_some(), "--release"),
379            (self.environment.is_some(), "--environment"),
380            (self.status.is_some(), "--status"),
381            (self.limit.is_some(), "--limit"),
382            (self.min_duration_ms.is_some(), "--min-duration-ms"),
383            (self.pagination.is_some(), "--pagination"),
384            (self.cursor_time.is_some(), "--cursor-time"),
385            (self.cursor_id.is_some(), "--cursor-id"),
386        ])
387    }
388
389    /// Returns the first filter that log reads cannot apply.
390    #[must_use]
391    pub(crate) fn first_log_unsupported_flag(&self) -> Option<&'static str> {
392        first_present_flag([
393            (self.name.is_some(), "--name"),
394            (self.user.is_some(), "--user"),
395            (self.status.is_some(), "--status"),
396            (self.min_duration_ms.is_some(), "--min-duration-ms"),
397        ])
398    }
399
400    /// Returns the first filter that issue list reads cannot apply.
401    #[must_use]
402    pub(crate) fn first_issue_list_unsupported_flag(&self) -> Option<&'static str> {
403        first_present_flag([
404            (self.name.is_some(), "--name"),
405            (self.user.is_some(), "--user"),
406            (self.trace.is_some(), "--trace"),
407            (self.level.is_some(), "--severity"),
408            (self.search.is_some(), "--search"),
409            (self.min_duration_ms.is_some(), "--min-duration-ms"),
410        ])
411    }
412
413    /// Returns the first filter that action reads cannot apply.
414    #[must_use]
415    pub(crate) fn first_action_unsupported_flag(&self) -> Option<&'static str> {
416        first_present_flag([
417            (self.trace.is_some(), "--trace"),
418            (self.level.is_some(), "--severity"),
419            (self.search.is_some(), "--search"),
420            (self.status.is_some(), "--status"),
421            (self.min_duration_ms.is_some(), "--min-duration-ms"),
422        ])
423    }
424
425    /// Returns the first filter that release reads cannot apply.
426    #[must_use]
427    pub(crate) fn first_release_unsupported_flag(&self) -> Option<&'static str> {
428        first_present_flag([
429            (self.name.is_some(), "--name"),
430            (self.user.is_some(), "--user"),
431            (self.trace.is_some(), "--trace"),
432            (self.level.is_some(), "--severity"),
433            (self.search.is_some(), "--search"),
434            (self.status.is_some(), "--status"),
435            (self.min_duration_ms.is_some(), "--min-duration-ms"),
436            (self.pagination.is_some(), "--pagination"),
437            (self.cursor_time.is_some(), "--cursor-time"),
438            (self.cursor_id.is_some(), "--cursor-id"),
439        ])
440    }
441
442    /// Returns the first filter that recent trace discovery cannot apply.
443    #[must_use]
444    pub(crate) fn first_trace_list_unsupported_flag(&self) -> Option<&'static str> {
445        first_present_flag([
446            (self.name.is_some(), "--name"),
447            (self.user.is_some(), "--user"),
448            (self.trace.is_some(), "--trace"),
449            (self.level.is_some(), "--severity"),
450            (self.search.is_some(), "--search"),
451            (self.pagination.is_some(), "--pagination"),
452            (self.cursor_time.is_some(), "--cursor-time"),
453            (self.cursor_id.is_some(), "--cursor-id"),
454        ])
455    }
456}
457
458/// Returns the first present flag in declaration order.
459fn first_present_flag<const N: usize>(flags: [(bool, &'static str); N]) -> Option<&'static str> {
460    flags
461        .iter()
462        .find_map(|(present, flag)| present.then_some(*flag))
463}
464
465/// Live stream target for `watch`.
466#[derive(Debug, Clone, Copy, PartialEq, Eq)]
467pub enum WatchTarget {
468    /// All supported live event types.
469    All,
470    /// Structured logs.
471    Logs,
472    /// Grouped issues.
473    Issues,
474    /// Product actions.
475    Actions,
476}
477
478/// Client-side filters for live watch commands.
479#[derive(Debug, Clone, Default, PartialEq, Eq)]
480pub struct WatchOptions {
481    /// Canonical severity filters for logs and issues.
482    pub severity: Vec<String>,
483}
484
485/// Optional metadata for backend-owned project setup tracking.
486#[derive(Debug, Clone, Default, PartialEq, Eq)]
487pub struct ProjectSetupSeenOptions {
488    /// Runtime or framework observed by setup.
489    pub runtime: Option<String>,
490    /// Setup source for account-token calls.
491    pub source: Option<String>,
492    /// Release environment observed by setup.
493    pub environment: Option<String>,
494}
495
496/// Fields accepted by secure project creation.
497#[derive(Debug, Clone, PartialEq, Eq)]
498pub struct ProjectCreateOptions {
499    /// Trimmed project name.
500    pub name: String,
501    /// Optional trimmed runtime.
502    pub runtime: Option<String>,
503    /// Optional trimmed environment.
504    pub environment: Option<String>,
505    /// Owner-selected destination for the one-time ingest key.
506    pub ingest_key_file: String,
507    /// Explicitly discard a mismatched pending retry before creating.
508    pub abandon_retry: bool,
509}
510
511/// Apple native debug-artifact operation.
512#[derive(Debug, Clone, PartialEq, Eq)]
513pub enum NativeDebugArtifactsTarget {
514    /// Validate, upload, and verify every supported object identity.
515    Upload(NativeDebugUploadOptions),
516    /// Verify one exact uploaded object identity.
517    Lookup(NativeDebugLookupOptions),
518}
519
520/// Shared exact native debug-artifact lookup scope.
521#[derive(Debug, Clone, PartialEq, Eq)]
522pub struct NativeDebugLookupOptions {
523    /// Account-owned project UUID.
524    pub project_id: String,
525    /// Exact release identifier.
526    pub release: String,
527    /// Exact environment identifier.
528    pub environment: String,
529    /// Exact service identifier.
530    pub service: String,
531    /// Canonical lowercase Mach-O image UUID.
532    pub image_uuid: String,
533    /// Supported canonical architecture.
534    pub architecture: String,
535}
536
537/// Apple native debug-artifact upload options.
538#[derive(Debug, Clone, PartialEq, Eq)]
539pub struct NativeDebugUploadOptions {
540    /// User-selected dSYM bundle, ZIP archive, or Mach-O debug object.
541    pub path: String,
542    /// Account-owned project UUID.
543    pub project_id: String,
544    /// Exact release identifier.
545    pub release: String,
546    /// Exact environment identifier.
547    pub environment: String,
548    /// Exact service identifier.
549    pub service: String,
550    /// Optional exact canonical image UUID gate for release automation.
551    pub expected_image_uuids: Vec<String>,
552    /// Validate locally without authentication or network mutation.
553    pub dry_run: bool,
554}
555
556/// Support-ticket operation.
557#[derive(Debug, Clone, PartialEq, Eq)]
558pub enum SupportTarget {
559    /// Create one support ticket.
560    Create(Box<SupportTicketCreateOptions>),
561    /// List support-ticket history.
562    List(Box<SupportTicketListOptions>),
563    /// Read one support ticket by public identifier.
564    Detail(String),
565    /// Read public context history for one support ticket.
566    ContextHistory {
567        /// Public ticket identifier.
568        ticket_id: String,
569    },
570    /// Add requested context to one support ticket.
571    ReplyContext(Box<SupportContextReplyOptions>),
572    /// Update one support ticket's public lifecycle status.
573    UpdateStatus {
574        /// Public ticket identifier.
575        ticket_id: String,
576        /// User-owned lifecycle status.
577        status: SupportTicketLifecycleStatus,
578    },
579}
580
581/// Fields accepted when replying with requested support context.
582#[derive(Debug, Clone, Default, PartialEq, Eq)]
583pub struct SupportContextReplyOptions {
584    /// Public ticket identifier.
585    pub ticket_id: String,
586    /// User-provided support context.
587    pub context: String,
588    /// Required idempotency key used for exact retries.
589    pub retry_key: String,
590    /// Include bounded locally generated diagnostics.
591    pub diagnostics: bool,
592}
593
594/// User-owned support-ticket lifecycle status.
595#[derive(Debug, Clone, Copy, PartialEq, Eq)]
596pub enum SupportTicketLifecycleStatus {
597    /// Reopen a ticket.
598    Open,
599    /// Close a ticket.
600    Closed,
601}
602
603impl SupportTicketLifecycleStatus {
604    /// Returns the canonical API status value.
605    #[must_use]
606    pub const fn as_str(self) -> &'static str {
607        match self {
608            Self::Open => "open",
609            Self::Closed => "closed",
610        }
611    }
612}
613
614/// Fields accepted when creating a support ticket.
615#[derive(Debug, Clone, Default, PartialEq, Eq)]
616pub struct SupportTicketCreateOptions {
617    /// Public support category.
618    pub category: String,
619    /// Concise ticket title.
620    pub title: String,
621    /// Reproducible description supplied by the user.
622    pub description: String,
623    /// Optional project identifier.
624    pub project_id: Option<String>,
625    /// Optional deployment environment.
626    pub environment: Option<String>,
627    /// Optional runtime.
628    pub runtime: Option<String>,
629    /// Optional framework.
630    pub framework: Option<String>,
631    /// Optional SDK package.
632    pub sdk_package: Option<String>,
633    /// Optional SDK version.
634    pub sdk_version: Option<String>,
635    /// Optional release.
636    pub release: Option<String>,
637    /// Optional trace identifier.
638    pub trace_id: Option<String>,
639    /// Optional event identifier.
640    pub event_id: Option<String>,
641    /// Include bounded locally generated diagnostics.
642    pub diagnostics: bool,
643}
644
645/// Filters for support-ticket history.
646#[derive(Debug, Clone, Default, PartialEq, Eq)]
647pub struct SupportTicketListOptions {
648    /// Optional project identifier.
649    pub project_id: Option<String>,
650    /// Optional ticket status.
651    pub status: Option<String>,
652    /// Optional ticket source.
653    pub source: Option<String>,
654    /// Optional support category.
655    pub category: Option<String>,
656    /// Optional release.
657    pub release: Option<String>,
658    /// Optional result limit.
659    pub limit: Option<String>,
660    /// Optional explicit pagination mode.
661    pub pagination: Option<String>,
662    /// Optional continuation timestamp.
663    pub cursor_time: Option<String>,
664    /// Optional continuation identifier.
665    pub cursor_id: Option<String>,
666}
667
668/// Context target for `explain`.
669#[derive(Debug, Clone, PartialEq, Eq)]
670pub enum ExplainTarget {
671    /// One issue by ID.
672    Issue(String),
673    /// One trace by ID.
674    Trace(String),
675}
676
677/// Mutation target for `set`.
678#[derive(Debug, Clone, PartialEq, Eq)]
679pub enum SetTarget {
680    /// Update one issue status.
681    IssueStatus {
682        /// Issue identifier.
683        id: String,
684        /// New issue status.
685        status: String,
686    },
687}
688
689/// Process environment needed by the CLI.
690#[derive(Debug, Clone, PartialEq, Eq)]
691pub struct CliEnvironment {
692    /// Base API URL.
693    pub base_url: String,
694    /// Optional bearer token.
695    pub token: Option<String>,
696    /// Optional home directory.
697    pub home: Option<std::path::PathBuf>,
698    /// Optional current working directory.
699    pub cwd: Option<std::path::PathBuf>,
700}
701
702impl CliEnvironment {
703    /// Loads CLI environment from process variables.
704    #[must_use]
705    pub fn from_process() -> Self {
706        Self {
707            base_url: std::env::var("LOGBREW_API_URL")
708                .unwrap_or_else(|_| String::from("https://api.logbrew.co")),
709            token: std::env::var("LOGBREW_TOKEN").ok(),
710            home: std::env::var_os("HOME").map(std::path::PathBuf::from),
711            cwd: std::env::current_dir().ok(),
712        }
713    }
714}
715
716impl Command {
717    /// Returns the HTTP API path for commands backed by a single REST request.
718    #[must_use]
719    pub fn http_path(&self) -> Option<String> {
720        match self {
721            Self::Read {
722                target, options, ..
723            } => Some(read_path(
724                target,
725                &ReadPathFilters {
726                    name: options.name.as_deref(),
727                    service: options.service.as_deref(),
728                    since: options.since.as_deref(),
729                    user: options.user.as_deref(),
730                    trace: options.trace.as_deref(),
731                    level: options.level.as_deref(),
732                    search: options.search.as_deref(),
733                    project: options.project.as_deref(),
734                    release: options.release.as_deref(),
735                    environment: options.environment.as_deref(),
736                    status: options.status.as_deref(),
737                    limit: options.limit.as_deref(),
738                    min_duration_ms: options.min_duration_ms.as_deref(),
739                    pagination: options.pagination.as_deref(),
740                    cursor_time: options.cursor_time.as_deref(),
741                    cursor_id: options.cursor_id.as_deref(),
742                },
743            )),
744            Self::Explain { target, .. } => Some(explain_path(target)),
745            Self::Set { target, .. } => Some(set_path(target)),
746            Self::ProjectSetupSeen { project_id, .. } => {
747                Some(format!("/api/projects/{project_id}/setup/seen"))
748            }
749            Self::ProjectCreate { .. } | Self::Projects { .. } => {
750                Some(String::from("/api/projects"))
751            }
752            Self::Support { target, .. } => Some(support::path(target)),
753            Self::Help { .. }
754            | Self::Login { .. }
755            | Self::Logout { .. }
756            | Self::Setup { .. }
757            | Self::Status { .. }
758            | Self::Doctor { .. }
759            | Self::Usage { .. }
760            | Self::Version { .. }
761            | Self::InvestigateIssue { .. }
762            | Self::NativeDebugArtifacts { .. }
763            | Self::Watch { .. } => None,
764        }
765    }
766
767    /// Returns whether command output should be JSON.
768    #[must_use]
769    pub const fn wants_json(&self) -> bool {
770        match self {
771            Self::Help { json, .. }
772            | Self::Login { json, .. }
773            | Self::Logout { json }
774            | Self::Status { json }
775            | Self::Doctor { json, .. }
776            | Self::ProjectCreate { json, .. }
777            | Self::Projects { json }
778            | Self::Usage { json }
779            | Self::Version { json }
780            | Self::Read { json, .. }
781            | Self::Watch { json, .. }
782            | Self::Explain { json, .. }
783            | Self::InvestigateIssue { json, .. }
784            | Self::NativeDebugArtifacts { json, .. }
785            | Self::Set { json, .. }
786            | Self::ProjectSetupSeen { json, .. }
787            | Self::Support { json, .. }
788            | Self::Setup { json, .. } => *json,
789        }
790    }
791
792    /// Returns the HTTP method for commands backed by a REST request.
793    #[must_use]
794    pub const fn http_method(&self) -> Option<HttpMethod> {
795        match self {
796            Self::ProjectCreate { .. }
797            | Self::ProjectSetupSeen { .. }
798            | Self::Support {
799                target: SupportTarget::Create(_),
800                ..
801            }
802            | Self::Support {
803                target: SupportTarget::ReplyContext(_),
804                ..
805            } => Some(HttpMethod::Post),
806            Self::Support {
807                target: SupportTarget::UpdateStatus { .. },
808                ..
809            }
810            | Self::Set { .. } => Some(HttpMethod::Patch),
811            Self::Projects { .. }
812            | Self::Read { .. }
813            | Self::Explain { .. }
814            | Self::Support { .. } => Some(HttpMethod::Get),
815            Self::Help { .. }
816            | Self::Login { .. }
817            | Self::Logout { .. }
818            | Self::Setup { .. }
819            | Self::Status { .. }
820            | Self::Doctor { .. }
821            | Self::Usage { .. }
822            | Self::Version { .. }
823            | Self::InvestigateIssue { .. }
824            | Self::NativeDebugArtifacts { .. }
825            | Self::Watch { .. } => None,
826        }
827    }
828
829    /// Returns JSON request body for mutation commands.
830    #[must_use]
831    pub fn request_body(&self) -> Option<serde_json::Value> {
832        self.request_body_for_token(None)
833    }
834
835    /// Returns JSON request body for mutation commands with auth-aware defaults.
836    #[must_use]
837    fn request_body_for_token(&self, token: Option<&str>) -> Option<serde_json::Value> {
838        match self {
839            Self::Set {
840                target: SetTarget::IssueStatus { status, .. },
841                ..
842            } => Some(serde_json::json!({ "status": status })),
843            Self::ProjectSetupSeen { options, .. } => Some(project_setup_seen_body(options, token)),
844            Self::ProjectCreate { options, .. } => Some(project_create_body(options)),
845            Self::Support {
846                target: SupportTarget::Create(options),
847                ..
848            } => Some(support::create_body(options)),
849            Self::Support {
850                target: SupportTarget::ReplyContext(options),
851                ..
852            } => Some(support::context_body(options)),
853            Self::Support {
854                target: SupportTarget::UpdateStatus { status, .. },
855                ..
856            } => Some(serde_json::json!({"status": status.as_str()})),
857            Self::Help { .. }
858            | Self::Login { .. }
859            | Self::Logout { .. }
860            | Self::Setup { .. }
861            | Self::Status { .. }
862            | Self::Doctor { .. }
863            | Self::Projects { .. }
864            | Self::Usage { .. }
865            | Self::Version { .. }
866            | Self::Read { .. }
867            | Self::Watch { .. }
868            | Self::Explain { .. }
869            | Self::InvestigateIssue { .. }
870            | Self::NativeDebugArtifacts { .. }
871            | Self::Support { .. } => None,
872        }
873    }
874
875    /// Returns an idempotency key for support context replies.
876    fn idempotency_key(&self) -> Option<&str> {
877        match self {
878            Self::Support {
879                target: SupportTarget::ReplyContext(options),
880                ..
881            } => Some(options.retry_key.as_str()),
882            Self::Help { .. }
883            | Self::Login { .. }
884            | Self::Logout { .. }
885            | Self::Setup { .. }
886            | Self::Status { .. }
887            | Self::Doctor { .. }
888            | Self::Usage { .. }
889            | Self::Version { .. }
890            | Self::Read { .. }
891            | Self::Watch { .. }
892            | Self::Explain { .. }
893            | Self::InvestigateIssue { .. }
894            | Self::NativeDebugArtifacts { .. }
895            | Self::Set { .. }
896            | Self::ProjectSetupSeen { .. }
897            | Self::ProjectCreate { .. }
898            | Self::Projects { .. }
899            | Self::Support { .. } => None,
900        }
901    }
902}
903
904/// HTTP method used by a CLI command.
905#[derive(Debug, Clone, Copy, PartialEq, Eq)]
906pub enum HttpMethod {
907    /// GET request.
908    Get,
909    /// POST request.
910    Post,
911    /// PATCH request.
912    Patch,
913}
914
915/// Builds the `setup/seen` request body without local setup state.
916fn project_setup_seen_body(
917    options: &ProjectSetupSeenOptions,
918    token: Option<&str>,
919) -> serde_json::Value {
920    let mut body = serde_json::Map::new();
921    if let Some(runtime) = options.runtime.as_ref() {
922        drop(body.insert(
923            "runtime".to_owned(),
924            serde_json::Value::String(runtime.clone()),
925        ));
926    }
927    if let Some(source) = setup_seen_source(options, token) {
928        drop(body.insert("source".to_owned(), serde_json::Value::String(source)));
929    }
930    if let Some(environment) = options.environment.as_ref() {
931        drop(body.insert(
932            "environment".to_owned(),
933            serde_json::Value::String(environment.clone()),
934        ));
935    }
936    serde_json::Value::Object(body)
937}
938
939/// Builds the byte-stable project creation request surface.
940fn project_create_body(options: &ProjectCreateOptions) -> serde_json::Value {
941    let mut body = serde_json::Map::new();
942    drop(body.insert(
943        "name".to_owned(),
944        serde_json::Value::String(options.name.clone()),
945    ));
946    if let Some(runtime) = options.runtime.as_ref() {
947        drop(body.insert(
948            "runtime".to_owned(),
949            serde_json::Value::String(runtime.clone()),
950        ));
951    }
952    if let Some(environment) = options.environment.as_ref() {
953        drop(body.insert(
954            "environment".to_owned(),
955            serde_json::Value::String(environment.clone()),
956        ));
957    }
958    drop(body.insert(
959        "source".to_owned(),
960        serde_json::Value::String(String::from("cli")),
961    ));
962    serde_json::Value::Object(body)
963}
964
965/// Resolves setup source while preserving ingest-key identity derivation.
966fn setup_seen_source(options: &ProjectSetupSeenOptions, token: Option<&str>) -> Option<String> {
967    if token_is_project_ingest_key(token) {
968        return None;
969    }
970    Some(options.source.as_deref().unwrap_or("cli").to_owned())
971}
972
973/// Executes a parsed command.
974///
975/// # Errors
976///
977/// Returns [`RuntimeError`] if output, browser launch, auth, or HTTP fails.
978pub async fn execute_command<W: std::io::Write>(
979    command: &Command,
980    env: &CliEnvironment,
981    output: &mut W,
982) -> Result<(), RuntimeError> {
983    match command {
984        Command::Help { topic, json } => execute_help(*topic, *json, output),
985        Command::Login { open_browser, json } => {
986            execute_login(env, *open_browser, *json, output).await
987        }
988        Command::Logout { json } => execute_logout(env, *json, output).await,
989        Command::Setup { auto, yes, json } => execute_setup(env, *auto, *yes, *json, output),
990        Command::Status { json } => execute_status(env, *json, output).await,
991        Command::Doctor { project_id, json } => {
992            doctor::execute(env, project_id.as_str(), *json, output).await
993        }
994        Command::ProjectCreate { options, json } => {
995            project_create::execute(env, options, *json, output).await
996        }
997        Command::Projects { json } => projects::execute(env, *json, output).await,
998        Command::Usage { json } => usage::execute(env, *json, output).await,
999        Command::Version { json } => execute_version(*json, output),
1000        Command::InvestigateIssue { issue_id, json } => {
1001            investigate::execute(env, issue_id.as_str(), *json, output).await
1002        }
1003        Command::NativeDebugArtifacts { target, json } => {
1004            native_debug_artifacts::execute(env, target, *json, output).await
1005        }
1006        Command::Read { .. }
1007        | Command::Explain { .. }
1008        | Command::Set { .. }
1009        | Command::ProjectSetupSeen { .. }
1010        | Command::Support { .. } => execute_http(command, env, output).await,
1011        Command::Watch {
1012            target,
1013            options,
1014            json,
1015        } => execute_watch(env, *target, options, *json, output).await,
1016    }
1017}
1018
1019/// Emits CLI help.
1020fn execute_help<W: std::io::Write>(
1021    topic: HelpTopic,
1022    json: bool,
1023    output: &mut W,
1024) -> Result<(), RuntimeError> {
1025    let help = help::help_text(topic);
1026    if json {
1027        let body = serde_json::json!({
1028            "ok": true,
1029            "topic": topic.key(),
1030            "help": help,
1031        });
1032        writeln!(output, "{body}")?;
1033    } else {
1034        writeln!(output, "{help}")?;
1035    }
1036    Ok(())
1037}
1038
1039/// Executes setup planning.
1040fn execute_setup<W: std::io::Write>(
1041    env: &CliEnvironment,
1042    auto: bool,
1043    yes: bool,
1044    json: bool,
1045    output: &mut W,
1046) -> Result<(), RuntimeError> {
1047    write_setup_plan(env.cwd.as_deref(), auto, yes, json, output)?;
1048    Ok(())
1049}
1050
1051/// Executes commands backed by one HTTP request.
1052async fn execute_http<W: std::io::Write>(
1053    command: &Command,
1054    env: &CliEnvironment,
1055    output: &mut W,
1056) -> Result<(), RuntimeError> {
1057    let path = command.http_path().ok_or(CliError::UnknownCommand)?;
1058    let url = format!("{}{}", env.base_url.trim_end_matches('/'), path);
1059    let client = reqwest::Client::builder()
1060        .timeout(std::time::Duration::from_secs(30))
1061        .connect_timeout(std::time::Duration::from_secs(10))
1062        .build()?;
1063
1064    let support_command = matches!(command, Command::Support { .. });
1065    let response_result = send_authenticated_with_refresh(&client, env, |client, credential| {
1066        build_command_request(client, command, url.as_str(), credential)
1067    })
1068    .await;
1069    let (response, credential) = match response_result {
1070        Ok(response) => response,
1071        Err(RuntimeError::Http(_)) if support_command => return Err(support_transport_error()),
1072        Err(error) => return Err(error),
1073    };
1074    let status = response.status();
1075    let body = match response.text().await {
1076        Ok(body) => body,
1077        Err(_) if support_command => return Err(support_transport_error()),
1078        Err(error) => return Err(RuntimeError::Http(error)),
1079    };
1080
1081    if !status.is_success() {
1082        let body = if let Command::Support { target, .. } = command {
1083            support::safe_error_body(target, status.as_u16())
1084        } else {
1085            credential.redact_response_body(body.as_str())
1086        };
1087        return Err(RuntimeError::Api {
1088            status: status.as_u16(),
1089            body,
1090            auth_source: credential.source(),
1091            auth_label: credential.label(),
1092        });
1093    }
1094
1095    write_api_success(command, body.as_str(), output)?;
1096    Ok(())
1097}
1098
1099/// Returns a fixed, path-free support transport failure.
1100const fn support_transport_error() -> RuntimeError {
1101    RuntimeError::Unavailable {
1102        message: "support request could not be completed",
1103        next: "check network connectivity and retry the support command",
1104    }
1105}
1106
1107/// Builds one command request with the supplied credential.
1108fn build_command_request(
1109    client: &reqwest::Client,
1110    command: &Command,
1111    url: &str,
1112    credential: &AuthCredential,
1113) -> reqwest::RequestBuilder {
1114    let mut request = match command.http_method().unwrap_or(HttpMethod::Get) {
1115        HttpMethod::Get => client.get(url),
1116        HttpMethod::Post => client.post(url),
1117        HttpMethod::Patch => client.patch(url),
1118    }
1119    .bearer_auth(credential.token());
1120    if let Some(body) = command.request_body_for_token(Some(credential.token())) {
1121        request = request.json(&body);
1122    }
1123    if let Some(key) = command.idempotency_key() {
1124        request = request.header("Idempotency-Key", key);
1125    }
1126    request
1127}
1128
1129/// Executes the public live WebSocket watch flow.
1130async fn execute_watch<W: std::io::Write>(
1131    env: &CliEnvironment,
1132    target: WatchTarget,
1133    options: &WatchOptions,
1134    json: bool,
1135    output: &mut W,
1136) -> Result<(), RuntimeError> {
1137    if !json {
1138        return Err(RuntimeError::Unavailable {
1139            message: "watch streams JSON for agents",
1140            next: "run logbrew watch --json",
1141        });
1142    }
1143
1144    let mut reconnect_backoff = WatchReconnectBackoff::default();
1145    loop {
1146        let ticket = match request_feed_ticket(env).await {
1147            Ok(ticket) => ticket,
1148            Err(error) if reconnect_backoff.connected_once() && !runtime_error_is_auth(&error) => {
1149                tokio::time::sleep(reconnect_backoff.next_delay()).await;
1150                continue;
1151            }
1152            Err(error) => return Err(error),
1153        };
1154        let live_url = feed_live_url(env.base_url.as_str(), ticket.as_str())?;
1155        let (mut websocket, _) = match connect_async(live_url.as_str()).await {
1156            Ok(connection) => connection,
1157            Err(error)
1158                if reconnect_backoff.connected_once() && !websocket_error_is_auth(&error) =>
1159            {
1160                tokio::time::sleep(reconnect_backoff.next_delay()).await;
1161                continue;
1162            }
1163            Err(error) => return Err(map_websocket_connect_error(error)),
1164        };
1165        reconnect_backoff.mark_connected();
1166
1167        let mut emitted_before_disconnect = false;
1168        loop {
1169            let Some(message) = websocket.next().await else {
1170                break;
1171            };
1172            let message = match message {
1173                Ok(message) => message,
1174                Err(error) if websocket_error_is_auth(&error) => {
1175                    return Err(map_websocket_stream_error(error));
1176                }
1177                Err(_) => break,
1178            };
1179            match message {
1180                Message::Text(text) => {
1181                    let event = parse_live_event(text.as_str())?;
1182                    if watch_event_matches(target, options, &event) {
1183                        writeln!(output, "{event}")?;
1184                    }
1185                    emitted_before_disconnect = true;
1186                }
1187                Message::Binary(_) | Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => {}
1188                Message::Close(_) => return Ok(()),
1189            }
1190        }
1191        if emitted_before_disconnect {
1192            reconnect_backoff.reset();
1193        }
1194        tokio::time::sleep(reconnect_backoff.next_delay()).await;
1195    }
1196}
1197
1198/// Reconnect state for long-running live watch streams.
1199#[derive(Debug, Default)]
1200struct WatchReconnectBackoff {
1201    /// Whether a live WebSocket connection has ever been established.
1202    connected_once: bool,
1203    /// Consecutive reconnect attempts since the last stable event.
1204    attempts: u32,
1205}
1206
1207impl WatchReconnectBackoff {
1208    /// Returns whether the stream has connected at least once.
1209    const fn connected_once(&self) -> bool {
1210        self.connected_once
1211    }
1212
1213    /// Records a successful WebSocket connection.
1214    const fn mark_connected(&mut self) {
1215        self.connected_once = true;
1216    }
1217
1218    /// Resets retry delay after a stream successfully emits data.
1219    const fn reset(&mut self) {
1220        self.attempts = 0;
1221    }
1222
1223    /// Returns the next capped exponential reconnect delay.
1224    fn next_delay(&mut self) -> std::time::Duration {
1225        let exponent = self.attempts.min(5);
1226        let multiplier = 1_u64 << exponent;
1227        self.attempts = self.attempts.saturating_add(1);
1228        let base = WATCH_RECONNECT_INITIAL_DELAY
1229            .as_secs()
1230            .saturating_mul(multiplier)
1231            .min(WATCH_RECONNECT_MAX_DELAY.as_secs());
1232        let delay = std::time::Duration::from_secs(base) + watch_reconnect_jitter();
1233        if delay > WATCH_RECONNECT_MAX_DELAY {
1234            WATCH_RECONNECT_MAX_DELAY
1235        } else {
1236            delay
1237        }
1238    }
1239}
1240
1241/// Returns small jitter for reconnect delays without adding a random dependency.
1242fn watch_reconnect_jitter() -> std::time::Duration {
1243    let Ok(elapsed) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) else {
1244        return std::time::Duration::ZERO;
1245    };
1246    std::time::Duration::from_millis(
1247        u64::from(elapsed.subsec_millis()) % WATCH_RECONNECT_JITTER_MAX_MILLIS,
1248    )
1249}
1250
1251/// Returns whether a runtime error should stop watch reconnect attempts.
1252const fn runtime_error_is_auth(error: &RuntimeError) -> bool {
1253    matches!(
1254        error,
1255        RuntimeError::MissingToken | RuntimeError::Api { status: 401, .. }
1256    )
1257}
1258
1259/// Returns whether a WebSocket error is an auth failure.
1260fn websocket_error_is_auth(error: &WebSocketError) -> bool {
1261    matches!(error, WebSocketError::Http(response) if response.status().as_u16() == 401)
1262}
1263
1264/// Requests a short-lived WebSocket feed ticket from the public API.
1265async fn request_feed_ticket(env: &CliEnvironment) -> Result<String, RuntimeError> {
1266    let url = format!("{}/api/feed/ticket", env.base_url.trim_end_matches('/'));
1267    let client = reqwest::Client::builder()
1268        .timeout(std::time::Duration::from_secs(30))
1269        .connect_timeout(std::time::Duration::from_secs(10))
1270        .build()?;
1271    let (response, credential) =
1272        send_authenticated_with_refresh(&client, env, |client, credential| {
1273            client.post(url.as_str()).bearer_auth(credential.token())
1274        })
1275        .await?;
1276    let status = response.status();
1277    let body = response.text().await?;
1278    if !status.is_success() {
1279        return Err(RuntimeError::Api {
1280            status: status.as_u16(),
1281            body: credential.redact_response_body(body.as_str()),
1282            auth_source: credential.source(),
1283            auth_label: credential.label(),
1284        });
1285    }
1286
1287    let value = serde_json::from_str::<serde_json::Value>(body.as_str()).map_err(|_| {
1288        RuntimeError::Unavailable {
1289            message: "feed ticket response was not valid JSON",
1290            next: "retry logbrew watch or run logbrew status",
1291        }
1292    })?;
1293    value
1294        .get("ticket")
1295        .and_then(serde_json::Value::as_str)
1296        .map(str::trim)
1297        .filter(|ticket| !ticket.is_empty())
1298        .map(ToOwned::to_owned)
1299        .ok_or(RuntimeError::Unavailable {
1300            message: "feed ticket response did not include a ticket",
1301            next: "retry logbrew watch or run logbrew status",
1302        })
1303}
1304
1305/// Builds the WebSocket live feed URL without exposing the opaque ticket elsewhere.
1306fn feed_live_url(base_url: &str, ticket: &str) -> Result<String, RuntimeError> {
1307    let trimmed = base_url.trim_end_matches('/');
1308    let (scheme, rest) = websocket_base_parts(trimmed).ok_or(RuntimeError::Unavailable {
1309        message: "LOGBREW_API_URL must start with http:// or https://",
1310        next: "check LOGBREW_API_URL or run logbrew status",
1311    })?;
1312    Ok(format!(
1313        "{scheme}://{rest}/api/feed/live?ticket={}",
1314        encode_component(ticket)
1315    ))
1316}
1317
1318/// Converts an HTTP API base URL into WebSocket scheme and authority/path base parts.
1319fn websocket_base_parts(base_url: &str) -> Option<(&'static str, &str)> {
1320    base_url
1321        .strip_prefix("https://")
1322        .map(|rest| ("wss", rest))
1323        .or_else(|| base_url.strip_prefix("http://").map(|rest| ("ws", rest)))
1324}
1325
1326/// Parses one backend live event object.
1327fn parse_live_event(text: &str) -> Result<serde_json::Value, RuntimeError> {
1328    serde_json::from_str::<serde_json::Value>(text).map_err(|_| RuntimeError::Unavailable {
1329        message: "live watch event was not valid JSON",
1330        next: "retry logbrew watch or check LOGBREW_API_URL",
1331    })
1332}
1333
1334/// Returns whether an event should be emitted for the requested watch target and filters.
1335fn watch_event_matches(
1336    target: WatchTarget,
1337    options: &WatchOptions,
1338    event: &serde_json::Value,
1339) -> bool {
1340    target_matches_event(target, event) && severity_matches(options, event)
1341}
1342
1343/// Returns whether the event type belongs to the selected target.
1344fn target_matches_event(target: WatchTarget, event: &serde_json::Value) -> bool {
1345    let event_type = event
1346        .get("type")
1347        .and_then(serde_json::Value::as_str)
1348        .unwrap_or_default();
1349    match target {
1350        WatchTarget::All => true,
1351        WatchTarget::Logs => event_type == "native_log",
1352        WatchTarget::Issues => event_type == "native_issue",
1353        WatchTarget::Actions => event_type == "native_action",
1354    }
1355}
1356
1357/// Applies client-side severity filters to log and issue events.
1358fn severity_matches(options: &WatchOptions, event: &serde_json::Value) -> bool {
1359    if options.severity.is_empty() {
1360        return true;
1361    }
1362    let Some(severity) = event
1363        .get("data")
1364        .and_then(|data| data.get("severity").or_else(|| data.get("level")))
1365        .and_then(serde_json::Value::as_str)
1366    else {
1367        return false;
1368    };
1369    options
1370        .severity
1371        .iter()
1372        .any(|allowed| allowed.as_str() == severity)
1373}
1374
1375/// Maps a WebSocket connection failure to a token-safe runtime error.
1376fn map_websocket_connect_error(error: WebSocketError) -> RuntimeError {
1377    match error {
1378        WebSocketError::Http(response) if response.status().as_u16() == 401 => {
1379            RuntimeError::Unavailable {
1380                message: "live watch ticket was rejected",
1381                next: "run logbrew login",
1382            }
1383        }
1384        WebSocketError::Http(_) => RuntimeError::Unavailable {
1385            message: "live watch websocket upgrade failed",
1386            next: "retry logbrew watch or check LOGBREW_API_URL",
1387        },
1388        WebSocketError::ConnectionClosed
1389        | WebSocketError::AlreadyClosed
1390        | WebSocketError::Io(_)
1391        | WebSocketError::Tls(_)
1392        | WebSocketError::Capacity(_)
1393        | WebSocketError::Protocol(_)
1394        | WebSocketError::WriteBufferFull(_)
1395        | WebSocketError::Utf8(_)
1396        | WebSocketError::AttackAttempt
1397        | WebSocketError::Url(_)
1398        | WebSocketError::HttpFormat(_) => RuntimeError::Unavailable {
1399            message: "live watch websocket failed",
1400            next: "retry logbrew watch or check LOGBREW_API_URL",
1401        },
1402    }
1403}
1404
1405/// Maps an established WebSocket stream failure to a token-safe runtime error.
1406fn map_websocket_stream_error(error: WebSocketError) -> RuntimeError {
1407    match error {
1408        WebSocketError::ConnectionClosed | WebSocketError::AlreadyClosed => {
1409            RuntimeError::Unavailable {
1410                message: "live watch websocket closed",
1411                next: "retry logbrew watch",
1412            }
1413        }
1414        WebSocketError::Http(response) if response.status().as_u16() == 401 => {
1415            RuntimeError::Unavailable {
1416                message: "live watch ticket was rejected",
1417                next: "run logbrew login",
1418            }
1419        }
1420        WebSocketError::Http(_)
1421        | WebSocketError::Io(_)
1422        | WebSocketError::Tls(_)
1423        | WebSocketError::Capacity(_)
1424        | WebSocketError::Protocol(_)
1425        | WebSocketError::WriteBufferFull(_)
1426        | WebSocketError::Utf8(_)
1427        | WebSocketError::AttackAttempt
1428        | WebSocketError::Url(_)
1429        | WebSocketError::HttpFormat(_) => RuntimeError::Unavailable {
1430            message: "live watch websocket failed",
1431            next: "retry logbrew watch or check LOGBREW_API_URL",
1432        },
1433    }
1434}
1435
1436/// Read endpoint filter values.
1437struct ReadPathFilters<'a> {
1438    /// Optional action name filter.
1439    name: Option<&'a str>,
1440    /// Optional service name filter.
1441    service: Option<&'a str>,
1442    /// Optional lower time bound.
1443    since: Option<&'a str>,
1444    /// Optional user or actor filter.
1445    user: Option<&'a str>,
1446    /// Optional trace ID filter.
1447    trace: Option<&'a str>,
1448    /// Optional log severity filter.
1449    level: Option<&'a str>,
1450    /// Optional log message substring search.
1451    search: Option<&'a str>,
1452    /// Optional project filter.
1453    project: Option<&'a str>,
1454    /// Optional release filter.
1455    release: Option<&'a str>,
1456    /// Optional environment filter.
1457    environment: Option<&'a str>,
1458    /// Optional issue status filter.
1459    status: Option<&'a str>,
1460    /// Optional row limit.
1461    limit: Option<&'a str>,
1462    /// Optional minimum end-to-end trace duration in milliseconds.
1463    min_duration_ms: Option<&'a str>,
1464    /// Optional pagination mode.
1465    pagination: Option<&'a str>,
1466    /// Optional continuation timestamp.
1467    cursor_time: Option<&'a str>,
1468    /// Optional continuation identifier.
1469    cursor_id: Option<&'a str>,
1470}
1471
1472/// Builds a read endpoint path.
1473fn read_path(target: &ReadTarget, filters: &ReadPathFilters<'_>) -> String {
1474    match target {
1475        ReadTarget::Logs => path_with_query(
1476            "/api/logs",
1477            &[
1478                ("service_name", filters.service),
1479                ("severity", filters.level),
1480                ("search", filters.search),
1481                ("since", filters.since),
1482                ("trace_id", filters.trace),
1483                ("project_id", filters.project),
1484                ("release", filters.release),
1485                ("environment", filters.environment),
1486                ("pagination", filters.pagination),
1487                ("cursor_time", filters.cursor_time),
1488                ("cursor_id", filters.cursor_id),
1489                ("limit", filters.limit),
1490            ],
1491        ),
1492        ReadTarget::Issues => path_with_query(
1493            "/api/telemetry/issues",
1494            &[
1495                ("service_name", filters.service),
1496                ("since", filters.since),
1497                ("status", filters.status),
1498                ("project_id", filters.project),
1499                ("release", filters.release),
1500                ("environment", filters.environment),
1501                ("pagination", filters.pagination),
1502                ("cursor_time", filters.cursor_time),
1503                ("cursor_id", filters.cursor_id),
1504                ("limit", filters.limit),
1505            ],
1506        ),
1507        ReadTarget::Actions => path_with_query(
1508            "/api/telemetry/actions",
1509            &[
1510                ("service_name", filters.service),
1511                ("name", filters.name),
1512                ("since", filters.since),
1513                ("distinct_id", filters.user),
1514                ("project_id", filters.project),
1515                ("release", filters.release),
1516                ("environment", filters.environment),
1517                ("pagination", filters.pagination),
1518                ("cursor_time", filters.cursor_time),
1519                ("cursor_id", filters.cursor_id),
1520                ("limit", filters.limit),
1521            ],
1522        ),
1523        ReadTarget::Releases => path_with_query(
1524            "/api/telemetry/releases",
1525            &[
1526                ("service_name", filters.service),
1527                ("since", filters.since),
1528                ("project_id", filters.project),
1529                ("release", filters.release),
1530                ("environment", filters.environment),
1531                ("limit", filters.limit),
1532            ],
1533        ),
1534        ReadTarget::Traces => path_with_query(
1535            "/api/telemetry/traces",
1536            &[
1537                ("project_id", filters.project),
1538                ("service_name", filters.service),
1539                ("release", filters.release),
1540                ("environment", filters.environment),
1541                ("status", filters.status),
1542                ("since", filters.since),
1543                ("min_duration_ms", filters.min_duration_ms),
1544                ("limit", filters.limit),
1545            ],
1546        ),
1547        ReadTarget::Trace(id) => path_with_query(
1548            &format!("/api/telemetry/traces/{}", encode_component(id)),
1549            &[
1550                ("project_id", filters.project),
1551                ("release", filters.release),
1552                ("environment", filters.environment),
1553            ],
1554        ),
1555        ReadTarget::Issue(id) => format!("/api/telemetry/issues/{}", encode_component(id)),
1556    }
1557}
1558
1559/// Builds an explain endpoint path.
1560fn explain_path(target: &ExplainTarget) -> String {
1561    match target {
1562        ExplainTarget::Issue(id) => format!("/api/telemetry/issues/{}", encode_component(id)),
1563        ExplainTarget::Trace(id) => format!("/api/telemetry/traces/{}", encode_component(id)),
1564    }
1565}
1566
1567/// Builds a mutation endpoint path.
1568fn set_path(target: &SetTarget) -> String {
1569    match target {
1570        SetTarget::IssueStatus { id, .. } => {
1571            format!("/api/telemetry/issues/{}", encode_component(id))
1572        }
1573    }
1574}
1575
1576/// Builds a path with query parameters.
1577fn path_with_query(path: &str, params: &[(&str, Option<&str>)]) -> String {
1578    let query = params
1579        .iter()
1580        .filter_map(|(name, value)| value.map(|v| format!("{name}={}", encode_component(v))))
1581        .collect::<Vec<_>>();
1582
1583    if query.is_empty() {
1584        path.to_owned()
1585    } else {
1586        format!("{path}?{}", query.join("&"))
1587    }
1588}
1589
1590/// Percent-encodes a path or query component without adding a dependency.
1591fn encode_component(value: &str) -> String {
1592    let mut encoded = String::new();
1593    for byte in value.bytes() {
1594        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
1595            encoded.push(char::from(byte));
1596        } else {
1597            encoded.push('%');
1598            encoded.push(hex_digit(byte >> 4));
1599            encoded.push(hex_digit(byte & 0x0f));
1600        }
1601    }
1602    encoded
1603}
1604
1605/// Converts a nibble to an uppercase hexadecimal digit.
1606fn hex_digit(nibble: u8) -> char {
1607    match nibble {
1608        0..=9 => char::from(b'0' + nibble),
1609        10..=15 => char::from(b'A' + (nibble - 10)),
1610        _ => '?',
1611    }
1612}