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