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;
13mod error;
14#[doc(hidden)]
15pub mod flags;
16pub mod help;
17#[doc(hidden)]
18pub mod ids;
19mod parser;
20#[doc(hidden)]
21pub mod render;
22#[doc(hidden)]
23pub mod setup;
24#[doc(hidden)]
25pub mod status;
26#[doc(hidden)]
27pub mod version;
28
29use auth::{open_browser, resolve_credential, write_logout_result};
30pub use error::{CliError, RuntimeError, write_cli_error, write_runtime_error};
31use futures_util::StreamExt as _;
32pub use parser::parse_command;
33use render::write_api_success;
34use setup::write_setup_plan;
35use status::execute_status;
36use tokio_tungstenite::connect_async;
37use tokio_tungstenite::tungstenite::{Error as WebSocketError, Message};
38use version::execute_version;
39
40/// Initial delay before reconnecting a live watch stream.
41const WATCH_RECONNECT_INITIAL_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
42/// Maximum delay before reconnecting a live watch stream.
43const WATCH_RECONNECT_MAX_DELAY: std::time::Duration = std::time::Duration::from_secs(30);
44/// Maximum jitter added to reconnect delays.
45const WATCH_RECONNECT_JITTER_MAX_MILLIS: u64 = 250;
46
47/// Accepted issue status values for generic recovery text.
48pub(crate) const ISSUE_STATUS_VALUES_NEXT_STEP: &str =
49    "use one of unresolved/open, resolved/closed, ignored";
50/// Accepted issue status values for read filter recovery text.
51pub(crate) const ISSUE_STATUS_FILTER_NEXT_STEP: &str =
52    "use --status unresolved/open, --status resolved/closed, or --status ignored";
53/// Accepted issue status values for missing mutation arguments.
54pub(crate) const ISSUE_STATUS_ARGUMENT_NEXT_STEP: &str =
55    "provide one of unresolved/open, resolved/closed, ignored";
56
57/// Parsed `LogBrew` command.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum Command {
60    /// Shows command usage.
61    Help {
62        /// Help topic to display.
63        topic: HelpTopic,
64        /// Emit machine-readable JSON.
65        json: bool,
66    },
67    /// Opens browser-based authentication.
68    Login {
69        /// Try to open the login URL in the default browser.
70        open_browser: bool,
71        /// Emit machine-readable JSON.
72        json: bool,
73    },
74    /// Removes the local CLI token.
75    Logout {
76        /// Emit machine-readable JSON.
77        json: bool,
78    },
79    /// Detects the current project and prints a non-mutating SDK setup plan.
80    Setup {
81        /// Let the CLI pick the framework or runtime automatically.
82        auto: bool,
83        /// Suppress confirmation prompts.
84        yes: bool,
85        /// Emit machine-readable JSON.
86        json: bool,
87    },
88    /// Checks local auth and server reachability.
89    Status {
90        /// Emit machine-readable JSON.
91        json: bool,
92    },
93    /// Prints the installed CLI version.
94    Version {
95        /// Emit machine-readable JSON.
96        json: bool,
97    },
98    /// Reads historical observability data.
99    Read {
100        /// Resource to read.
101        target: ReadTarget,
102        /// Read filters.
103        options: Box<ReadOptions>,
104        /// Emit machine-readable JSON.
105        json: bool,
106    },
107    /// Watches live observability data.
108    Watch {
109        /// Resource to watch.
110        target: WatchTarget,
111        /// Live watch filters applied client-side.
112        options: WatchOptions,
113        /// Emit machine-readable JSON.
114        json: bool,
115    },
116    /// Fetches context for an issue or trace so an agent can explain it.
117    Explain {
118        /// Resource to explain.
119        target: ExplainTarget,
120        /// Emit machine-readable JSON.
121        json: bool,
122    },
123    /// Mutates server-side state.
124    Set {
125        /// Target state mutation.
126        target: SetTarget,
127        /// Emit machine-readable JSON.
128        json: bool,
129    },
130}
131
132/// Help topic for CLI usage output.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum HelpTopic {
135    /// Root command overview.
136    Root,
137    /// Browser login command.
138    Login,
139    /// Local logout command.
140    Logout,
141    /// SDK setup command.
142    Setup,
143    /// Status check command.
144    Status,
145    /// Installed CLI version command.
146    Version,
147    /// Authentication workflow overview.
148    Auth,
149    /// Machine-readable output overview.
150    Json,
151    /// First-run examples and common workflows.
152    Examples,
153    /// Backend-owned project setup and ingest key workflow overview.
154    Projects,
155    /// Backend-owned account usage and quota workflow overview.
156    Usage,
157    /// Read command overview.
158    Read,
159    /// Log reading command.
160    ReadLogs,
161    /// Issue reading command.
162    ReadIssues,
163    /// Action reading command.
164    ReadActions,
165    /// Release reading command.
166    ReadReleases,
167    /// Trace reading command.
168    ReadTrace,
169    /// Single issue reading command.
170    ReadIssue,
171    /// Live watch command.
172    Watch,
173    /// Explain command.
174    Explain,
175    /// State mutation command.
176    Set,
177}
178
179impl HelpTopic {
180    /// Returns a stable machine-readable topic name.
181    #[must_use]
182    pub const fn key(self) -> &'static str {
183        match self {
184            Self::Root => "root",
185            Self::Login => "login",
186            Self::Logout => "logout",
187            Self::Setup => "setup",
188            Self::Status => "status",
189            Self::Version => "version",
190            Self::Auth => "auth",
191            Self::Json => "json",
192            Self::Examples => "examples",
193            Self::Projects => "projects",
194            Self::Usage => "usage",
195            Self::Read => "read",
196            Self::ReadLogs => "read_logs",
197            Self::ReadIssues => "read_issues",
198            Self::ReadActions => "read_actions",
199            Self::ReadReleases => "read_releases",
200            Self::ReadTrace => "read_trace",
201            Self::ReadIssue => "read_issue",
202            Self::Watch => "watch",
203            Self::Explain => "explain",
204            Self::Set => "set",
205        }
206    }
207}
208
209/// Historical data target for `read`.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub enum ReadTarget {
212    /// Structured logs.
213    Logs,
214    /// Grouped issues.
215    Issues,
216    /// Product actions.
217    Actions,
218    /// Release summaries.
219    Releases,
220    /// One trace by ID.
221    Trace(String),
222    /// One issue by ID.
223    Issue(String),
224}
225
226/// Filters for historical read commands.
227#[derive(Debug, Clone, Default, PartialEq, Eq)]
228pub struct ReadOptions {
229    /// Optional action name filter.
230    pub name: Option<String>,
231    /// Optional relative or absolute lower time bound.
232    pub since: Option<String>,
233    /// Optional user or actor filter.
234    pub user: Option<String>,
235    /// Optional trace ID filter.
236    pub trace: Option<String>,
237    /// Optional log severity filter.
238    pub level: Option<String>,
239    /// Optional log message substring search.
240    pub search: Option<String>,
241    /// Optional project filter.
242    pub project: Option<String>,
243    /// Optional release filter.
244    pub release: Option<String>,
245    /// Optional environment filter.
246    pub environment: Option<String>,
247    /// Optional issue status filter.
248    pub status: Option<String>,
249    /// Optional row limit.
250    pub limit: Option<String>,
251}
252
253impl ReadOptions {
254    /// Returns the first filter that trace-detail reads cannot apply.
255    #[must_use]
256    pub(crate) fn first_trace_detail_unsupported_flag(&self) -> Option<&'static str> {
257        first_present_flag([
258            (self.name.is_some(), "--name"),
259            (self.since.is_some(), "--since"),
260            (self.user.is_some(), "--user"),
261            (self.trace.is_some(), "--trace"),
262            (self.level.is_some(), "--severity"),
263            (self.search.is_some(), "--search"),
264            (self.status.is_some(), "--status"),
265            (self.limit.is_some(), "--limit"),
266        ])
267    }
268
269    /// Returns the first filter that issue-detail reads cannot apply.
270    #[must_use]
271    pub(crate) fn first_issue_detail_unsupported_flag(&self) -> Option<&'static str> {
272        first_present_flag([
273            (self.name.is_some(), "--name"),
274            (self.since.is_some(), "--since"),
275            (self.user.is_some(), "--user"),
276            (self.trace.is_some(), "--trace"),
277            (self.level.is_some(), "--severity"),
278            (self.search.is_some(), "--search"),
279            (self.project.is_some(), "--project"),
280            (self.release.is_some(), "--release"),
281            (self.environment.is_some(), "--environment"),
282            (self.status.is_some(), "--status"),
283            (self.limit.is_some(), "--limit"),
284        ])
285    }
286
287    /// Returns the first filter that log reads cannot apply.
288    #[must_use]
289    pub(crate) fn first_log_unsupported_flag(&self) -> Option<&'static str> {
290        first_present_flag([
291            (self.name.is_some(), "--name"),
292            (self.user.is_some(), "--user"),
293            (self.status.is_some(), "--status"),
294        ])
295    }
296
297    /// Returns the first filter that issue list reads cannot apply.
298    #[must_use]
299    pub(crate) fn first_issue_list_unsupported_flag(&self) -> Option<&'static str> {
300        first_present_flag([
301            (self.name.is_some(), "--name"),
302            (self.since.is_some(), "--since"),
303            (self.user.is_some(), "--user"),
304            (self.trace.is_some(), "--trace"),
305            (self.level.is_some(), "--severity"),
306            (self.search.is_some(), "--search"),
307        ])
308    }
309
310    /// Returns the first filter that action reads cannot apply.
311    #[must_use]
312    pub(crate) fn first_action_unsupported_flag(&self) -> Option<&'static str> {
313        first_present_flag([
314            (self.trace.is_some(), "--trace"),
315            (self.level.is_some(), "--severity"),
316            (self.search.is_some(), "--search"),
317            (self.status.is_some(), "--status"),
318        ])
319    }
320
321    /// Returns the first filter that release reads cannot apply.
322    #[must_use]
323    pub(crate) fn first_release_unsupported_flag(&self) -> Option<&'static str> {
324        first_present_flag([
325            (self.name.is_some(), "--name"),
326            (self.since.is_some(), "--since"),
327            (self.user.is_some(), "--user"),
328            (self.trace.is_some(), "--trace"),
329            (self.level.is_some(), "--severity"),
330            (self.search.is_some(), "--search"),
331            (self.status.is_some(), "--status"),
332        ])
333    }
334}
335
336/// Returns the first present flag in declaration order.
337fn first_present_flag<const N: usize>(flags: [(bool, &'static str); N]) -> Option<&'static str> {
338    flags
339        .iter()
340        .find_map(|(present, flag)| present.then_some(*flag))
341}
342
343/// Live stream target for `watch`.
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub enum WatchTarget {
346    /// All supported live event types.
347    All,
348    /// Structured logs.
349    Logs,
350    /// Grouped issues.
351    Issues,
352    /// Product actions.
353    Actions,
354}
355
356/// Client-side filters for live watch commands.
357#[derive(Debug, Clone, Default, PartialEq, Eq)]
358pub struct WatchOptions {
359    /// Canonical severity filters for logs and issues.
360    pub severity: Vec<String>,
361}
362
363/// Context target for `explain`.
364#[derive(Debug, Clone, PartialEq, Eq)]
365pub enum ExplainTarget {
366    /// One issue by ID.
367    Issue(String),
368    /// One trace by ID.
369    Trace(String),
370}
371
372/// Mutation target for `set`.
373#[derive(Debug, Clone, PartialEq, Eq)]
374pub enum SetTarget {
375    /// Update one issue status.
376    IssueStatus {
377        /// Issue identifier.
378        id: String,
379        /// New issue status.
380        status: String,
381    },
382}
383
384/// Process environment needed by the CLI.
385#[derive(Debug, Clone, PartialEq, Eq)]
386pub struct CliEnvironment {
387    /// Base API URL.
388    pub base_url: String,
389    /// Optional bearer token.
390    pub token: Option<String>,
391    /// Optional home directory.
392    pub home: Option<std::path::PathBuf>,
393    /// Optional current working directory.
394    pub cwd: Option<std::path::PathBuf>,
395}
396
397impl CliEnvironment {
398    /// Loads CLI environment from process variables.
399    #[must_use]
400    pub fn from_process() -> Self {
401        Self {
402            base_url: std::env::var("LOGBREW_API_URL")
403                .unwrap_or_else(|_| String::from("https://api.logbrew.co")),
404            token: std::env::var("LOGBREW_TOKEN").ok(),
405            home: std::env::var_os("HOME").map(std::path::PathBuf::from),
406            cwd: std::env::current_dir().ok(),
407        }
408    }
409}
410
411impl Command {
412    /// Returns the HTTP API path for commands backed by a single REST request.
413    #[must_use]
414    pub fn http_path(&self) -> Option<String> {
415        match self {
416            Self::Read {
417                target, options, ..
418            } => Some(read_path(
419                target,
420                &ReadPathFilters {
421                    name: options.name.as_deref(),
422                    since: options.since.as_deref(),
423                    user: options.user.as_deref(),
424                    trace: options.trace.as_deref(),
425                    level: options.level.as_deref(),
426                    search: options.search.as_deref(),
427                    project: options.project.as_deref(),
428                    release: options.release.as_deref(),
429                    environment: options.environment.as_deref(),
430                    status: options.status.as_deref(),
431                    limit: options.limit.as_deref(),
432                },
433            )),
434            Self::Explain { target, .. } => Some(explain_path(target)),
435            Self::Set { target, .. } => Some(set_path(target)),
436            Self::Help { .. }
437            | Self::Login { .. }
438            | Self::Logout { .. }
439            | Self::Setup { .. }
440            | Self::Status { .. }
441            | Self::Version { .. }
442            | Self::Watch { .. } => None,
443        }
444    }
445
446    /// Returns whether command output should be JSON.
447    #[must_use]
448    pub const fn wants_json(&self) -> bool {
449        match self {
450            Self::Help { json, .. }
451            | Self::Login { json, .. }
452            | Self::Logout { json }
453            | Self::Status { json }
454            | Self::Version { json }
455            | Self::Read { json, .. }
456            | Self::Watch { json, .. }
457            | Self::Explain { json, .. }
458            | Self::Set { json, .. }
459            | Self::Setup { json, .. } => *json,
460        }
461    }
462
463    /// Returns the HTTP method for commands backed by a REST request.
464    #[must_use]
465    pub const fn http_method(&self) -> Option<HttpMethod> {
466        match self {
467            Self::Read { .. } | Self::Explain { .. } => Some(HttpMethod::Get),
468            Self::Set { .. } => Some(HttpMethod::Patch),
469            Self::Help { .. }
470            | Self::Login { .. }
471            | Self::Logout { .. }
472            | Self::Setup { .. }
473            | Self::Status { .. }
474            | Self::Version { .. }
475            | Self::Watch { .. } => None,
476        }
477    }
478
479    /// Returns JSON request body for mutation commands.
480    #[must_use]
481    pub fn request_body(&self) -> Option<serde_json::Value> {
482        match self {
483            Self::Set {
484                target: SetTarget::IssueStatus { status, .. },
485                ..
486            } => Some(serde_json::json!({ "status": status })),
487            Self::Help { .. }
488            | Self::Login { .. }
489            | Self::Logout { .. }
490            | Self::Setup { .. }
491            | Self::Status { .. }
492            | Self::Version { .. }
493            | Self::Read { .. }
494            | Self::Watch { .. }
495            | Self::Explain { .. } => None,
496        }
497    }
498}
499
500/// HTTP method used by a CLI command.
501#[derive(Debug, Clone, Copy, PartialEq, Eq)]
502pub enum HttpMethod {
503    /// GET request.
504    Get,
505    /// PATCH request.
506    Patch,
507}
508
509/// Executes a parsed command.
510///
511/// # Errors
512///
513/// Returns [`RuntimeError`] if output, browser launch, auth, or HTTP fails.
514pub async fn execute_command<W: std::io::Write>(
515    command: &Command,
516    env: &CliEnvironment,
517    output: &mut W,
518) -> Result<(), RuntimeError> {
519    match command {
520        Command::Help { topic, json } => execute_help(*topic, *json, output),
521        Command::Login { open_browser, json } => execute_login(env, *open_browser, *json, output),
522        Command::Logout { json } => execute_logout(env, *json, output),
523        Command::Setup { auto, yes, json } => execute_setup(env, *auto, *yes, *json, output),
524        Command::Status { json } => execute_status(env, *json, output).await,
525        Command::Version { json } => execute_version(*json, output),
526        Command::Read { .. } | Command::Explain { .. } | Command::Set { .. } => {
527            execute_http(command, env, output).await
528        }
529        Command::Watch {
530            target,
531            options,
532            json,
533        } => execute_watch(env, *target, options, *json, output).await,
534    }
535}
536
537/// Emits CLI help.
538fn execute_help<W: std::io::Write>(
539    topic: HelpTopic,
540    json: bool,
541    output: &mut W,
542) -> Result<(), RuntimeError> {
543    let help = help::help_text(topic);
544    if json {
545        let body = serde_json::json!({
546            "ok": true,
547            "topic": topic.key(),
548            "help": help,
549        });
550        writeln!(output, "{body}")?;
551    } else {
552        writeln!(output, "{help}")?;
553    }
554    Ok(())
555}
556
557/// Executes browser login bootstrap.
558fn execute_login<W: std::io::Write>(
559    env: &CliEnvironment,
560    should_open_browser: bool,
561    json: bool,
562    output: &mut W,
563) -> Result<(), RuntimeError> {
564    let auth_url = format!("{}/api/auth/cli/login", env.base_url.trim_end_matches('/'));
565    let opened = should_open_browser && open_browser(auth_url.as_str());
566
567    if json {
568        let body = serde_json::json!({
569            "ok": true,
570            "auth_url": auth_url,
571            "browser_opened": opened,
572            "next": "open auth_url in a browser",
573        });
574        writeln!(output, "{body}")?;
575    } else {
576        writeln!(output, "Open this URL to log in: {auth_url}")?;
577        writeln!(
578            output,
579            "Browser: {}",
580            if opened { "opened" } else { "not opened" }
581        )?;
582        writeln!(output, "Next: open the URL in a browser")?;
583    }
584    Ok(())
585}
586
587/// Executes local logout.
588fn execute_logout<W: std::io::Write>(
589    env: &CliEnvironment,
590    json: bool,
591    output: &mut W,
592) -> Result<(), RuntimeError> {
593    write_logout_result(env, json, output)?;
594    Ok(())
595}
596
597/// Executes setup planning.
598fn execute_setup<W: std::io::Write>(
599    env: &CliEnvironment,
600    auto: bool,
601    yes: bool,
602    json: bool,
603    output: &mut W,
604) -> Result<(), RuntimeError> {
605    write_setup_plan(env.cwd.as_deref(), auto, yes, json, output)?;
606    Ok(())
607}
608
609/// Executes commands backed by one HTTP request.
610async fn execute_http<W: std::io::Write>(
611    command: &Command,
612    env: &CliEnvironment,
613    output: &mut W,
614) -> Result<(), RuntimeError> {
615    let path = command.http_path().ok_or(CliError::UnknownCommand)?;
616    let url = format!("{}{}", env.base_url.trim_end_matches('/'), path);
617    let client = reqwest::Client::builder()
618        .timeout(std::time::Duration::from_secs(30))
619        .connect_timeout(std::time::Duration::from_secs(10))
620        .build()?;
621
622    let mut request = match command.http_method().unwrap_or(HttpMethod::Get) {
623        HttpMethod::Get => client.get(url),
624        HttpMethod::Patch => client.patch(url),
625    };
626
627    let credential = resolve_credential(env)?;
628    request = request.bearer_auth(credential.token);
629
630    if let Some(body) = command.request_body() {
631        request = request.json(&body);
632    }
633
634    let response = request.send().await?;
635    let status = response.status();
636    let body = response.text().await?;
637
638    if !status.is_success() {
639        return Err(RuntimeError::Api {
640            status: status.as_u16(),
641            body,
642            auth_source: credential.source,
643            auth_label: credential.label,
644        });
645    }
646
647    write_api_success(command, body.as_str(), output)?;
648    Ok(())
649}
650
651/// Executes the public live WebSocket watch flow.
652async fn execute_watch<W: std::io::Write>(
653    env: &CliEnvironment,
654    target: WatchTarget,
655    options: &WatchOptions,
656    json: bool,
657    output: &mut W,
658) -> Result<(), RuntimeError> {
659    if !json {
660        return Err(RuntimeError::Unavailable {
661            message: "watch streams JSON for agents",
662            next: "run logbrew watch --json",
663        });
664    }
665
666    let credential = resolve_credential(env)?;
667    let mut reconnect_backoff = WatchReconnectBackoff::default();
668    loop {
669        let ticket = match request_feed_ticket(env, &credential).await {
670            Ok(ticket) => ticket,
671            Err(error) if reconnect_backoff.connected_once() && !runtime_error_is_auth(&error) => {
672                tokio::time::sleep(reconnect_backoff.next_delay()).await;
673                continue;
674            }
675            Err(error) => return Err(error),
676        };
677        let live_url = feed_live_url(env.base_url.as_str(), ticket.as_str())?;
678        let (mut websocket, _) = match connect_async(live_url.as_str()).await {
679            Ok(connection) => connection,
680            Err(error)
681                if reconnect_backoff.connected_once() && !websocket_error_is_auth(&error) =>
682            {
683                tokio::time::sleep(reconnect_backoff.next_delay()).await;
684                continue;
685            }
686            Err(error) => return Err(map_websocket_connect_error(error)),
687        };
688        reconnect_backoff.mark_connected();
689
690        let mut emitted_before_disconnect = false;
691        loop {
692            let Some(message) = websocket.next().await else {
693                break;
694            };
695            let message = match message {
696                Ok(message) => message,
697                Err(error) if websocket_error_is_auth(&error) => {
698                    return Err(map_websocket_stream_error(error));
699                }
700                Err(_) => break,
701            };
702            match message {
703                Message::Text(text) => {
704                    let event = parse_live_event(text.as_str())?;
705                    if watch_event_matches(target, options, &event) {
706                        writeln!(output, "{event}")?;
707                    }
708                    emitted_before_disconnect = true;
709                }
710                Message::Binary(_) | Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => {}
711                Message::Close(_) => return Ok(()),
712            }
713        }
714        if emitted_before_disconnect {
715            reconnect_backoff.reset();
716        }
717        tokio::time::sleep(reconnect_backoff.next_delay()).await;
718    }
719}
720
721/// Reconnect state for long-running live watch streams.
722#[derive(Debug, Default)]
723struct WatchReconnectBackoff {
724    /// Whether a live WebSocket connection has ever been established.
725    connected_once: bool,
726    /// Consecutive reconnect attempts since the last stable event.
727    attempts: u32,
728}
729
730impl WatchReconnectBackoff {
731    /// Returns whether the stream has connected at least once.
732    const fn connected_once(&self) -> bool {
733        self.connected_once
734    }
735
736    /// Records a successful WebSocket connection.
737    const fn mark_connected(&mut self) {
738        self.connected_once = true;
739    }
740
741    /// Resets retry delay after a stream successfully emits data.
742    const fn reset(&mut self) {
743        self.attempts = 0;
744    }
745
746    /// Returns the next capped exponential reconnect delay.
747    fn next_delay(&mut self) -> std::time::Duration {
748        let exponent = self.attempts.min(5);
749        let multiplier = 1_u64 << exponent;
750        self.attempts = self.attempts.saturating_add(1);
751        let base = WATCH_RECONNECT_INITIAL_DELAY
752            .as_secs()
753            .saturating_mul(multiplier)
754            .min(WATCH_RECONNECT_MAX_DELAY.as_secs());
755        let delay = std::time::Duration::from_secs(base) + watch_reconnect_jitter();
756        if delay > WATCH_RECONNECT_MAX_DELAY {
757            WATCH_RECONNECT_MAX_DELAY
758        } else {
759            delay
760        }
761    }
762}
763
764/// Returns small jitter for reconnect delays without adding a random dependency.
765fn watch_reconnect_jitter() -> std::time::Duration {
766    let Ok(elapsed) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) else {
767        return std::time::Duration::ZERO;
768    };
769    std::time::Duration::from_millis(
770        u64::from(elapsed.subsec_millis()) % WATCH_RECONNECT_JITTER_MAX_MILLIS,
771    )
772}
773
774/// Returns whether a runtime error should stop watch reconnect attempts.
775const fn runtime_error_is_auth(error: &RuntimeError) -> bool {
776    matches!(
777        error,
778        RuntimeError::MissingToken | RuntimeError::Api { status: 401, .. }
779    )
780}
781
782/// Returns whether a WebSocket error is an auth failure.
783fn websocket_error_is_auth(error: &WebSocketError) -> bool {
784    matches!(error, WebSocketError::Http(response) if response.status().as_u16() == 401)
785}
786
787/// Requests a short-lived WebSocket feed ticket from the public API.
788async fn request_feed_ticket(
789    env: &CliEnvironment,
790    credential: &auth::AuthCredential,
791) -> Result<String, RuntimeError> {
792    let url = format!("{}/api/feed/ticket", env.base_url.trim_end_matches('/'));
793    let client = reqwest::Client::builder()
794        .timeout(std::time::Duration::from_secs(30))
795        .connect_timeout(std::time::Duration::from_secs(10))
796        .build()?;
797    let response = client
798        .post(url)
799        .bearer_auth(credential.token.as_str())
800        .send()
801        .await?;
802    let status = response.status();
803    let body = response.text().await?;
804    if !status.is_success() {
805        return Err(RuntimeError::Api {
806            status: status.as_u16(),
807            body,
808            auth_source: credential.source,
809            auth_label: credential.label,
810        });
811    }
812
813    let value = serde_json::from_str::<serde_json::Value>(body.as_str()).map_err(|_| {
814        RuntimeError::Unavailable {
815            message: "feed ticket response was not valid JSON",
816            next: "retry logbrew watch or run logbrew status",
817        }
818    })?;
819    value
820        .get("ticket")
821        .and_then(serde_json::Value::as_str)
822        .map(str::trim)
823        .filter(|ticket| !ticket.is_empty())
824        .map(ToOwned::to_owned)
825        .ok_or(RuntimeError::Unavailable {
826            message: "feed ticket response did not include a ticket",
827            next: "retry logbrew watch or run logbrew status",
828        })
829}
830
831/// Builds the WebSocket live feed URL without exposing the opaque ticket elsewhere.
832fn feed_live_url(base_url: &str, ticket: &str) -> Result<String, RuntimeError> {
833    let trimmed = base_url.trim_end_matches('/');
834    let (scheme, rest) = websocket_base_parts(trimmed).ok_or(RuntimeError::Unavailable {
835        message: "LOGBREW_API_URL must start with http:// or https://",
836        next: "check LOGBREW_API_URL or run logbrew status",
837    })?;
838    Ok(format!(
839        "{scheme}://{rest}/api/feed/live?ticket={}",
840        encode_component(ticket)
841    ))
842}
843
844/// Converts an HTTP API base URL into WebSocket scheme and authority/path base parts.
845fn websocket_base_parts(base_url: &str) -> Option<(&'static str, &str)> {
846    base_url
847        .strip_prefix("https://")
848        .map(|rest| ("wss", rest))
849        .or_else(|| base_url.strip_prefix("http://").map(|rest| ("ws", rest)))
850}
851
852/// Parses one backend live event object.
853fn parse_live_event(text: &str) -> Result<serde_json::Value, RuntimeError> {
854    serde_json::from_str::<serde_json::Value>(text).map_err(|_| RuntimeError::Unavailable {
855        message: "live watch event was not valid JSON",
856        next: "retry logbrew watch or check LOGBREW_API_URL",
857    })
858}
859
860/// Returns whether an event should be emitted for the requested watch target and filters.
861fn watch_event_matches(
862    target: WatchTarget,
863    options: &WatchOptions,
864    event: &serde_json::Value,
865) -> bool {
866    target_matches_event(target, event) && severity_matches(options, event)
867}
868
869/// Returns whether the event type belongs to the selected target.
870fn target_matches_event(target: WatchTarget, event: &serde_json::Value) -> bool {
871    let event_type = event
872        .get("type")
873        .and_then(serde_json::Value::as_str)
874        .unwrap_or_default();
875    match target {
876        WatchTarget::All => true,
877        WatchTarget::Logs => event_type == "native_log",
878        WatchTarget::Issues => event_type == "native_issue",
879        WatchTarget::Actions => event_type == "native_action",
880    }
881}
882
883/// Applies client-side severity filters to log and issue events.
884fn severity_matches(options: &WatchOptions, event: &serde_json::Value) -> bool {
885    if options.severity.is_empty() {
886        return true;
887    }
888    let Some(severity) = event
889        .get("data")
890        .and_then(|data| data.get("severity").or_else(|| data.get("level")))
891        .and_then(serde_json::Value::as_str)
892    else {
893        return false;
894    };
895    options
896        .severity
897        .iter()
898        .any(|allowed| allowed.as_str() == severity)
899}
900
901/// Maps a WebSocket connection failure to a token-safe runtime error.
902fn map_websocket_connect_error(error: WebSocketError) -> RuntimeError {
903    match error {
904        WebSocketError::Http(response) if response.status().as_u16() == 401 => {
905            RuntimeError::Unavailable {
906                message: "live watch ticket was rejected",
907                next: "run logbrew login",
908            }
909        }
910        WebSocketError::Http(_) => RuntimeError::Unavailable {
911            message: "live watch websocket upgrade failed",
912            next: "retry logbrew watch or check LOGBREW_API_URL",
913        },
914        WebSocketError::ConnectionClosed
915        | WebSocketError::AlreadyClosed
916        | WebSocketError::Io(_)
917        | WebSocketError::Tls(_)
918        | WebSocketError::Capacity(_)
919        | WebSocketError::Protocol(_)
920        | WebSocketError::WriteBufferFull(_)
921        | WebSocketError::Utf8(_)
922        | WebSocketError::AttackAttempt
923        | WebSocketError::Url(_)
924        | WebSocketError::HttpFormat(_) => RuntimeError::Unavailable {
925            message: "live watch websocket failed",
926            next: "retry logbrew watch or check LOGBREW_API_URL",
927        },
928    }
929}
930
931/// Maps an established WebSocket stream failure to a token-safe runtime error.
932fn map_websocket_stream_error(error: WebSocketError) -> RuntimeError {
933    match error {
934        WebSocketError::ConnectionClosed | WebSocketError::AlreadyClosed => {
935            RuntimeError::Unavailable {
936                message: "live watch websocket closed",
937                next: "retry logbrew watch",
938            }
939        }
940        WebSocketError::Http(response) if response.status().as_u16() == 401 => {
941            RuntimeError::Unavailable {
942                message: "live watch ticket was rejected",
943                next: "run logbrew login",
944            }
945        }
946        WebSocketError::Http(_)
947        | WebSocketError::Io(_)
948        | WebSocketError::Tls(_)
949        | WebSocketError::Capacity(_)
950        | WebSocketError::Protocol(_)
951        | WebSocketError::WriteBufferFull(_)
952        | WebSocketError::Utf8(_)
953        | WebSocketError::AttackAttempt
954        | WebSocketError::Url(_)
955        | WebSocketError::HttpFormat(_) => RuntimeError::Unavailable {
956            message: "live watch websocket failed",
957            next: "retry logbrew watch or check LOGBREW_API_URL",
958        },
959    }
960}
961
962/// Read endpoint filter values.
963struct ReadPathFilters<'a> {
964    /// Optional action name filter.
965    name: Option<&'a str>,
966    /// Optional lower time bound.
967    since: Option<&'a str>,
968    /// Optional user or actor filter.
969    user: Option<&'a str>,
970    /// Optional trace ID filter.
971    trace: Option<&'a str>,
972    /// Optional log severity filter.
973    level: Option<&'a str>,
974    /// Optional log message substring search.
975    search: Option<&'a str>,
976    /// Optional project filter.
977    project: Option<&'a str>,
978    /// Optional release filter.
979    release: Option<&'a str>,
980    /// Optional environment filter.
981    environment: Option<&'a str>,
982    /// Optional issue status filter.
983    status: Option<&'a str>,
984    /// Optional row limit.
985    limit: Option<&'a str>,
986}
987
988/// Builds a read endpoint path.
989fn read_path(target: &ReadTarget, filters: &ReadPathFilters<'_>) -> String {
990    match target {
991        ReadTarget::Logs => path_with_query(
992            "/api/logs",
993            &[
994                ("severity", filters.level),
995                ("search", filters.search),
996                ("since", filters.since),
997                ("trace_id", filters.trace),
998                ("project_id", filters.project),
999                ("release", filters.release),
1000                ("environment", filters.environment),
1001                ("limit", filters.limit),
1002            ],
1003        ),
1004        ReadTarget::Issues => path_with_query(
1005            "/api/telemetry/issues",
1006            &[
1007                ("status", filters.status),
1008                ("project_id", filters.project),
1009                ("release", filters.release),
1010                ("environment", filters.environment),
1011                ("limit", filters.limit),
1012            ],
1013        ),
1014        ReadTarget::Actions => path_with_query(
1015            "/api/telemetry/actions",
1016            &[
1017                ("name", filters.name),
1018                ("since", filters.since),
1019                ("distinct_id", filters.user),
1020                ("project_id", filters.project),
1021                ("release", filters.release),
1022                ("environment", filters.environment),
1023                ("limit", filters.limit),
1024            ],
1025        ),
1026        ReadTarget::Releases => path_with_query(
1027            "/api/telemetry/releases",
1028            &[
1029                ("project_id", filters.project),
1030                ("release", filters.release),
1031                ("environment", filters.environment),
1032                ("limit", filters.limit),
1033            ],
1034        ),
1035        ReadTarget::Trace(id) => path_with_query(
1036            &format!("/api/telemetry/traces/{}", encode_component(id)),
1037            &[
1038                ("project_id", filters.project),
1039                ("release", filters.release),
1040                ("environment", filters.environment),
1041            ],
1042        ),
1043        ReadTarget::Issue(id) => format!("/api/telemetry/issues/{}", encode_component(id)),
1044    }
1045}
1046
1047/// Builds an explain endpoint path.
1048fn explain_path(target: &ExplainTarget) -> String {
1049    match target {
1050        ExplainTarget::Issue(id) => format!("/api/telemetry/issues/{}", encode_component(id)),
1051        ExplainTarget::Trace(id) => format!("/api/telemetry/traces/{}", encode_component(id)),
1052    }
1053}
1054
1055/// Builds a mutation endpoint path.
1056fn set_path(target: &SetTarget) -> String {
1057    match target {
1058        SetTarget::IssueStatus { id, .. } => {
1059            format!("/api/telemetry/issues/{}", encode_component(id))
1060        }
1061    }
1062}
1063
1064/// Builds a path with query parameters.
1065fn path_with_query(path: &str, params: &[(&str, Option<&str>)]) -> String {
1066    let query = params
1067        .iter()
1068        .filter_map(|(name, value)| value.map(|v| format!("{name}={}", encode_component(v))))
1069        .collect::<Vec<_>>();
1070
1071    if query.is_empty() {
1072        path.to_owned()
1073    } else {
1074        format!("{path}?{}", query.join("&"))
1075    }
1076}
1077
1078/// Percent-encodes a path or query component without adding a dependency.
1079fn encode_component(value: &str) -> String {
1080    let mut encoded = String::new();
1081    for byte in value.bytes() {
1082        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
1083            encoded.push(char::from(byte));
1084        } else {
1085            encoded.push('%');
1086            encoded.push(hex_digit(byte >> 4));
1087            encoded.push(hex_digit(byte & 0x0f));
1088        }
1089    }
1090    encoded
1091}
1092
1093/// Converts a nibble to an uppercase hexadecimal digit.
1094fn hex_digit(nibble: u8) -> char {
1095    match nibble {
1096        0..=9 => char::from(b'0' + nibble),
1097        10..=15 => char::from(b'A' + (nibble - 10)),
1098        _ => '?',
1099    }
1100}