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