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