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