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