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};
31pub use parser::parse_command;
32use render::write_api_success;
33use setup::write_setup_plan;
34use status::execute_status;
35use version::execute_version;
36
37/// Accepted issue status values for generic recovery text.
38pub(crate) const ISSUE_STATUS_VALUES_NEXT_STEP: &str =
39    "use one of unresolved/open, resolved/closed, ignored";
40/// Accepted issue status values for read filter recovery text.
41pub(crate) const ISSUE_STATUS_FILTER_NEXT_STEP: &str =
42    "use --status unresolved/open, --status resolved/closed, or --status ignored";
43/// Accepted issue status values for missing mutation arguments.
44pub(crate) const ISSUE_STATUS_ARGUMENT_NEXT_STEP: &str =
45    "provide one of unresolved/open, resolved/closed, ignored";
46
47/// Parsed `LogBrew` command.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum Command {
50    /// Shows command usage.
51    Help {
52        /// Help topic to display.
53        topic: HelpTopic,
54        /// Emit machine-readable JSON.
55        json: bool,
56    },
57    /// Opens browser-based authentication.
58    Login {
59        /// Try to open the login URL in the default browser.
60        open_browser: bool,
61        /// Emit machine-readable JSON.
62        json: bool,
63    },
64    /// Removes the local CLI token.
65    Logout {
66        /// Emit machine-readable JSON.
67        json: bool,
68    },
69    /// Detects the current project and prints a non-mutating SDK setup plan.
70    Setup {
71        /// Let the CLI pick the framework or runtime automatically.
72        auto: bool,
73        /// Suppress confirmation prompts.
74        yes: bool,
75        /// Emit machine-readable JSON.
76        json: bool,
77    },
78    /// Checks local auth and server reachability.
79    Status {
80        /// Emit machine-readable JSON.
81        json: bool,
82    },
83    /// Prints the installed CLI version.
84    Version {
85        /// Emit machine-readable JSON.
86        json: bool,
87    },
88    /// Reads historical observability data.
89    Read {
90        /// Resource to read.
91        target: ReadTarget,
92        /// Read filters.
93        options: Box<ReadOptions>,
94        /// Emit machine-readable JSON.
95        json: bool,
96    },
97    /// Watches live observability data.
98    Watch {
99        /// Resource to watch.
100        target: WatchTarget,
101        /// Emit machine-readable JSON.
102        json: bool,
103    },
104    /// Fetches context for an issue or trace so an agent can explain it.
105    Explain {
106        /// Resource to explain.
107        target: ExplainTarget,
108        /// Emit machine-readable JSON.
109        json: bool,
110    },
111    /// Mutates server-side state.
112    Set {
113        /// Target state mutation.
114        target: SetTarget,
115        /// Emit machine-readable JSON.
116        json: bool,
117    },
118}
119
120/// Help topic for CLI usage output.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum HelpTopic {
123    /// Root command overview.
124    Root,
125    /// Browser login command.
126    Login,
127    /// Local logout command.
128    Logout,
129    /// SDK setup command.
130    Setup,
131    /// Status check command.
132    Status,
133    /// Installed CLI version command.
134    Version,
135    /// Authentication workflow overview.
136    Auth,
137    /// Machine-readable output overview.
138    Json,
139    /// Read command overview.
140    Read,
141    /// Log reading command.
142    ReadLogs,
143    /// Issue reading command.
144    ReadIssues,
145    /// Action reading command.
146    ReadActions,
147    /// Release reading command.
148    ReadReleases,
149    /// Trace reading command.
150    ReadTrace,
151    /// Single issue reading command.
152    ReadIssue,
153    /// Live watch command.
154    Watch,
155    /// Explain command.
156    Explain,
157    /// State mutation command.
158    Set,
159}
160
161impl HelpTopic {
162    /// Returns a stable machine-readable topic name.
163    #[must_use]
164    pub const fn key(self) -> &'static str {
165        match self {
166            Self::Root => "root",
167            Self::Login => "login",
168            Self::Logout => "logout",
169            Self::Setup => "setup",
170            Self::Status => "status",
171            Self::Version => "version",
172            Self::Auth => "auth",
173            Self::Json => "json",
174            Self::Read => "read",
175            Self::ReadLogs => "read_logs",
176            Self::ReadIssues => "read_issues",
177            Self::ReadActions => "read_actions",
178            Self::ReadReleases => "read_releases",
179            Self::ReadTrace => "read_trace",
180            Self::ReadIssue => "read_issue",
181            Self::Watch => "watch",
182            Self::Explain => "explain",
183            Self::Set => "set",
184        }
185    }
186}
187
188/// Historical data target for `read`.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub enum ReadTarget {
191    /// Structured logs.
192    Logs,
193    /// Grouped issues.
194    Issues,
195    /// Product actions.
196    Actions,
197    /// Release summaries.
198    Releases,
199    /// One trace by ID.
200    Trace(String),
201    /// One issue by ID.
202    Issue(String),
203}
204
205/// Filters for historical read commands.
206#[derive(Debug, Clone, Default, PartialEq, Eq)]
207pub struct ReadOptions {
208    /// Optional action name filter.
209    pub name: Option<String>,
210    /// Optional relative or absolute lower time bound.
211    pub since: Option<String>,
212    /// Optional user or actor filter.
213    pub user: Option<String>,
214    /// Optional trace ID filter.
215    pub trace: Option<String>,
216    /// Optional log level filter.
217    pub level: Option<String>,
218    /// Optional log message substring search.
219    pub search: Option<String>,
220    /// Optional project filter.
221    pub project: Option<String>,
222    /// Optional release filter.
223    pub release: Option<String>,
224    /// Optional environment filter.
225    pub environment: Option<String>,
226    /// Optional issue status filter.
227    pub status: Option<String>,
228    /// Optional row limit.
229    pub limit: Option<String>,
230}
231
232impl ReadOptions {
233    /// Returns the first filter that trace-detail reads cannot apply.
234    #[must_use]
235    pub(crate) fn first_trace_detail_unsupported_flag(&self) -> Option<&'static str> {
236        first_present_flag([
237            (self.name.is_some(), "--name"),
238            (self.since.is_some(), "--since"),
239            (self.user.is_some(), "--user"),
240            (self.trace.is_some(), "--trace"),
241            (self.level.is_some(), "--level"),
242            (self.search.is_some(), "--search"),
243            (self.status.is_some(), "--status"),
244            (self.limit.is_some(), "--limit"),
245        ])
246    }
247
248    /// Returns the first filter that issue-detail reads cannot apply.
249    #[must_use]
250    pub(crate) fn first_issue_detail_unsupported_flag(&self) -> Option<&'static str> {
251        first_present_flag([
252            (self.name.is_some(), "--name"),
253            (self.since.is_some(), "--since"),
254            (self.user.is_some(), "--user"),
255            (self.trace.is_some(), "--trace"),
256            (self.level.is_some(), "--level"),
257            (self.search.is_some(), "--search"),
258            (self.project.is_some(), "--project"),
259            (self.release.is_some(), "--release"),
260            (self.environment.is_some(), "--environment"),
261            (self.status.is_some(), "--status"),
262            (self.limit.is_some(), "--limit"),
263        ])
264    }
265
266    /// Returns the first filter that log reads cannot apply.
267    #[must_use]
268    pub(crate) fn first_log_unsupported_flag(&self) -> Option<&'static str> {
269        first_present_flag([
270            (self.name.is_some(), "--name"),
271            (self.user.is_some(), "--user"),
272            (self.status.is_some(), "--status"),
273        ])
274    }
275
276    /// Returns the first filter that issue list reads cannot apply.
277    #[must_use]
278    pub(crate) fn first_issue_list_unsupported_flag(&self) -> Option<&'static str> {
279        first_present_flag([
280            (self.name.is_some(), "--name"),
281            (self.since.is_some(), "--since"),
282            (self.user.is_some(), "--user"),
283            (self.trace.is_some(), "--trace"),
284            (self.level.is_some(), "--level"),
285            (self.search.is_some(), "--search"),
286        ])
287    }
288
289    /// Returns the first filter that action reads cannot apply.
290    #[must_use]
291    pub(crate) fn first_action_unsupported_flag(&self) -> Option<&'static str> {
292        first_present_flag([
293            (self.trace.is_some(), "--trace"),
294            (self.level.is_some(), "--level"),
295            (self.search.is_some(), "--search"),
296            (self.status.is_some(), "--status"),
297        ])
298    }
299
300    /// Returns the first filter that release reads cannot apply.
301    #[must_use]
302    pub(crate) fn first_release_unsupported_flag(&self) -> Option<&'static str> {
303        first_present_flag([
304            (self.name.is_some(), "--name"),
305            (self.since.is_some(), "--since"),
306            (self.user.is_some(), "--user"),
307            (self.trace.is_some(), "--trace"),
308            (self.level.is_some(), "--level"),
309            (self.search.is_some(), "--search"),
310            (self.status.is_some(), "--status"),
311        ])
312    }
313}
314
315/// Returns the first present flag in declaration order.
316fn first_present_flag<const N: usize>(flags: [(bool, &'static str); N]) -> Option<&'static str> {
317    flags
318        .iter()
319        .find_map(|(present, flag)| present.then_some(*flag))
320}
321
322/// Live stream target for `watch`.
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub enum WatchTarget {
325    /// Structured logs.
326    Logs,
327    /// Product actions.
328    Actions,
329}
330
331/// Context target for `explain`.
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub enum ExplainTarget {
334    /// One issue by ID.
335    Issue(String),
336    /// One trace by ID.
337    Trace(String),
338}
339
340/// Mutation target for `set`.
341#[derive(Debug, Clone, PartialEq, Eq)]
342pub enum SetTarget {
343    /// Update one issue status.
344    IssueStatus {
345        /// Issue identifier.
346        id: String,
347        /// New issue status.
348        status: String,
349    },
350}
351
352/// Process environment needed by the CLI.
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct CliEnvironment {
355    /// Base API URL.
356    pub base_url: String,
357    /// Optional bearer token.
358    pub token: Option<String>,
359    /// Optional home directory.
360    pub home: Option<std::path::PathBuf>,
361    /// Optional current working directory.
362    pub cwd: Option<std::path::PathBuf>,
363}
364
365impl CliEnvironment {
366    /// Loads CLI environment from process variables.
367    #[must_use]
368    pub fn from_process() -> Self {
369        Self {
370            base_url: std::env::var("LOGBREW_API_URL")
371                .unwrap_or_else(|_| String::from("https://api.logbrew.co")),
372            token: std::env::var("LOGBREW_TOKEN").ok(),
373            home: std::env::var_os("HOME").map(std::path::PathBuf::from),
374            cwd: std::env::current_dir().ok(),
375        }
376    }
377}
378
379impl Command {
380    /// Returns the HTTP API path for commands backed by a single REST request.
381    #[must_use]
382    pub fn http_path(&self) -> Option<String> {
383        match self {
384            Self::Read {
385                target, options, ..
386            } => Some(read_path(
387                target,
388                &ReadPathFilters {
389                    name: options.name.as_deref(),
390                    since: options.since.as_deref(),
391                    user: options.user.as_deref(),
392                    trace: options.trace.as_deref(),
393                    level: options.level.as_deref(),
394                    search: options.search.as_deref(),
395                    project: options.project.as_deref(),
396                    release: options.release.as_deref(),
397                    environment: options.environment.as_deref(),
398                    status: options.status.as_deref(),
399                    limit: options.limit.as_deref(),
400                },
401            )),
402            Self::Explain { target, .. } => Some(explain_path(target)),
403            Self::Set { target, .. } => Some(set_path(target)),
404            Self::Help { .. }
405            | Self::Login { .. }
406            | Self::Logout { .. }
407            | Self::Setup { .. }
408            | Self::Status { .. }
409            | Self::Version { .. }
410            | Self::Watch { .. } => None,
411        }
412    }
413
414    /// Returns whether command output should be JSON.
415    #[must_use]
416    pub const fn wants_json(&self) -> bool {
417        match self {
418            Self::Help { json, .. }
419            | Self::Login { json, .. }
420            | Self::Logout { json }
421            | Self::Status { json }
422            | Self::Version { json }
423            | Self::Read { json, .. }
424            | Self::Watch { json, .. }
425            | Self::Explain { json, .. }
426            | Self::Set { json, .. }
427            | Self::Setup { json, .. } => *json,
428        }
429    }
430
431    /// Returns the HTTP method for commands backed by a REST request.
432    #[must_use]
433    pub const fn http_method(&self) -> Option<HttpMethod> {
434        match self {
435            Self::Read { .. } | Self::Explain { .. } => Some(HttpMethod::Get),
436            Self::Set { .. } => Some(HttpMethod::Patch),
437            Self::Help { .. }
438            | Self::Login { .. }
439            | Self::Logout { .. }
440            | Self::Setup { .. }
441            | Self::Status { .. }
442            | Self::Version { .. }
443            | Self::Watch { .. } => None,
444        }
445    }
446
447    /// Returns JSON request body for mutation commands.
448    #[must_use]
449    pub fn request_body(&self) -> Option<serde_json::Value> {
450        match self {
451            Self::Set {
452                target: SetTarget::IssueStatus { status, .. },
453                ..
454            } => Some(serde_json::json!({ "status": status })),
455            Self::Help { .. }
456            | Self::Login { .. }
457            | Self::Logout { .. }
458            | Self::Setup { .. }
459            | Self::Status { .. }
460            | Self::Version { .. }
461            | Self::Read { .. }
462            | Self::Watch { .. }
463            | Self::Explain { .. } => None,
464        }
465    }
466}
467
468/// HTTP method used by a CLI command.
469#[derive(Debug, Clone, Copy, PartialEq, Eq)]
470pub enum HttpMethod {
471    /// GET request.
472    Get,
473    /// PATCH request.
474    Patch,
475}
476
477/// Executes a parsed command.
478///
479/// # Errors
480///
481/// Returns [`RuntimeError`] if output, browser launch, auth, or HTTP fails.
482pub async fn execute_command<W: std::io::Write>(
483    command: &Command,
484    env: &CliEnvironment,
485    output: &mut W,
486) -> Result<(), RuntimeError> {
487    match command {
488        Command::Help { topic, json } => execute_help(*topic, *json, output),
489        Command::Login { open_browser, json } => execute_login(env, *open_browser, *json, output),
490        Command::Logout { json } => execute_logout(env, *json, output),
491        Command::Setup { auto, yes, json } => execute_setup(env, *auto, *yes, *json, output),
492        Command::Status { json } => execute_status(env, *json, output).await,
493        Command::Version { json } => execute_version(*json, output),
494        Command::Read { .. } | Command::Explain { .. } | Command::Set { .. } => {
495            execute_http(command, env, output).await
496        }
497        Command::Watch { target, .. } => execute_watch_placeholder(*target),
498    }
499}
500
501/// Emits CLI help.
502fn execute_help<W: std::io::Write>(
503    topic: HelpTopic,
504    json: bool,
505    output: &mut W,
506) -> Result<(), RuntimeError> {
507    let help = help::help_text(topic);
508    if json {
509        let body = serde_json::json!({
510            "ok": true,
511            "topic": topic.key(),
512            "help": help,
513        });
514        writeln!(output, "{body}")?;
515    } else {
516        writeln!(output, "{help}")?;
517    }
518    Ok(())
519}
520
521/// Executes browser login bootstrap.
522fn execute_login<W: std::io::Write>(
523    env: &CliEnvironment,
524    should_open_browser: bool,
525    json: bool,
526    output: &mut W,
527) -> Result<(), RuntimeError> {
528    let auth_url = format!("{}/api/auth/cli/login", env.base_url.trim_end_matches('/'));
529    let opened = should_open_browser && open_browser(auth_url.as_str());
530
531    if json {
532        let body = serde_json::json!({
533            "ok": true,
534            "auth_url": auth_url,
535            "browser_opened": opened,
536            "next": "open auth_url in a browser",
537        });
538        writeln!(output, "{body}")?;
539    } else {
540        writeln!(output, "Open this URL to log in: {auth_url}")?;
541        writeln!(
542            output,
543            "Browser: {}",
544            if opened { "opened" } else { "not opened" }
545        )?;
546        writeln!(output, "Next: open the URL in a browser")?;
547    }
548    Ok(())
549}
550
551/// Executes local logout.
552fn execute_logout<W: std::io::Write>(
553    env: &CliEnvironment,
554    json: bool,
555    output: &mut W,
556) -> Result<(), RuntimeError> {
557    write_logout_result(env, json, output)?;
558    Ok(())
559}
560
561/// Executes setup planning.
562fn execute_setup<W: std::io::Write>(
563    env: &CliEnvironment,
564    auto: bool,
565    yes: bool,
566    json: bool,
567    output: &mut W,
568) -> Result<(), RuntimeError> {
569    write_setup_plan(env.cwd.as_deref(), auto, yes, json, output)?;
570    Ok(())
571}
572
573/// Executes commands backed by one HTTP request.
574async fn execute_http<W: std::io::Write>(
575    command: &Command,
576    env: &CliEnvironment,
577    output: &mut W,
578) -> Result<(), RuntimeError> {
579    let path = command.http_path().ok_or(CliError::UnknownCommand)?;
580    let url = format!("{}{}", env.base_url.trim_end_matches('/'), path);
581    let client = reqwest::Client::builder()
582        .timeout(std::time::Duration::from_secs(30))
583        .connect_timeout(std::time::Duration::from_secs(10))
584        .build()?;
585
586    let mut request = match command.http_method().unwrap_or(HttpMethod::Get) {
587        HttpMethod::Get => client.get(url),
588        HttpMethod::Patch => client.patch(url),
589    };
590
591    let credential = resolve_credential(env)?;
592    request = request.bearer_auth(credential.token);
593
594    if let Some(body) = command.request_body() {
595        request = request.json(&body);
596    }
597
598    let response = request.send().await?;
599    let status = response.status();
600    let body = response.text().await?;
601
602    if !status.is_success() {
603        return Err(RuntimeError::Api {
604            status: status.as_u16(),
605            body,
606            auth_source: credential.source,
607            auth_label: credential.label,
608        });
609    }
610
611    write_api_success(command, body.as_str(), output)?;
612    Ok(())
613}
614
615/// Returns a stable unavailable error for reserved watch commands.
616const fn execute_watch_placeholder(target: WatchTarget) -> Result<(), RuntimeError> {
617    Err(RuntimeError::Unavailable {
618        message: "watch is reserved for the live stream transport",
619        next: watch_next_step(target),
620    })
621}
622
623/// Returns the historical command fallback while live watch is reserved.
624const fn watch_next_step(target: WatchTarget) -> &'static str {
625    match target {
626        WatchTarget::Logs => "use logbrew logs for historical data until live watch is available",
627        WatchTarget::Actions => {
628            "use logbrew actions for historical data until live watch is available"
629        }
630    }
631}
632
633/// Read endpoint filter values.
634struct ReadPathFilters<'a> {
635    /// Optional action name filter.
636    name: Option<&'a str>,
637    /// Optional lower time bound.
638    since: Option<&'a str>,
639    /// Optional user or actor filter.
640    user: Option<&'a str>,
641    /// Optional trace ID filter.
642    trace: Option<&'a str>,
643    /// Optional log level filter.
644    level: Option<&'a str>,
645    /// Optional log message substring search.
646    search: Option<&'a str>,
647    /// Optional project filter.
648    project: Option<&'a str>,
649    /// Optional release filter.
650    release: Option<&'a str>,
651    /// Optional environment filter.
652    environment: Option<&'a str>,
653    /// Optional issue status filter.
654    status: Option<&'a str>,
655    /// Optional row limit.
656    limit: Option<&'a str>,
657}
658
659/// Builds a read endpoint path.
660fn read_path(target: &ReadTarget, filters: &ReadPathFilters<'_>) -> String {
661    match target {
662        ReadTarget::Logs => path_with_query(
663            "/api/logs",
664            &[
665                ("level", filters.level),
666                ("search", filters.search),
667                ("since", filters.since),
668                ("trace_id", filters.trace),
669                ("project_id", filters.project),
670                ("release", filters.release),
671                ("environment", filters.environment),
672                ("limit", filters.limit),
673            ],
674        ),
675        ReadTarget::Issues => path_with_query(
676            "/api/telemetry/issues",
677            &[
678                ("status", filters.status),
679                ("project_id", filters.project),
680                ("release", filters.release),
681                ("environment", filters.environment),
682                ("limit", filters.limit),
683            ],
684        ),
685        ReadTarget::Actions => path_with_query(
686            "/api/telemetry/actions",
687            &[
688                ("name", filters.name),
689                ("since", filters.since),
690                ("distinct_id", filters.user),
691                ("project_id", filters.project),
692                ("release", filters.release),
693                ("environment", filters.environment),
694                ("limit", filters.limit),
695            ],
696        ),
697        ReadTarget::Releases => path_with_query(
698            "/api/telemetry/releases",
699            &[
700                ("project_id", filters.project),
701                ("release", filters.release),
702                ("environment", filters.environment),
703                ("limit", filters.limit),
704            ],
705        ),
706        ReadTarget::Trace(id) => path_with_query(
707            &format!("/api/telemetry/traces/{}", encode_component(id)),
708            &[
709                ("project_id", filters.project),
710                ("release", filters.release),
711                ("environment", filters.environment),
712            ],
713        ),
714        ReadTarget::Issue(id) => format!("/api/telemetry/issues/{}", encode_component(id)),
715    }
716}
717
718/// Builds an explain endpoint path.
719fn explain_path(target: &ExplainTarget) -> String {
720    match target {
721        ExplainTarget::Issue(id) => format!("/api/telemetry/issues/{}", encode_component(id)),
722        ExplainTarget::Trace(id) => format!("/api/telemetry/traces/{}", encode_component(id)),
723    }
724}
725
726/// Builds a mutation endpoint path.
727fn set_path(target: &SetTarget) -> String {
728    match target {
729        SetTarget::IssueStatus { id, .. } => {
730            format!("/api/telemetry/issues/{}", encode_component(id))
731        }
732    }
733}
734
735/// Builds a path with query parameters.
736fn path_with_query(path: &str, params: &[(&str, Option<&str>)]) -> String {
737    let query = params
738        .iter()
739        .filter_map(|(name, value)| value.map(|v| format!("{name}={}", encode_component(v))))
740        .collect::<Vec<_>>();
741
742    if query.is_empty() {
743        path.to_owned()
744    } else {
745        format!("{path}?{}", query.join("&"))
746    }
747}
748
749/// Percent-encodes a path or query component without adding a dependency.
750fn encode_component(value: &str) -> String {
751    let mut encoded = String::new();
752    for byte in value.bytes() {
753        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
754            encoded.push(char::from(byte));
755        } else {
756            encoded.push('%');
757            encoded.push(hex_digit(byte >> 4));
758            encoded.push(hex_digit(byte & 0x0f));
759        }
760    }
761    encoded
762}
763
764/// Converts a nibble to an uppercase hexadecimal digit.
765fn hex_digit(nibble: u8) -> char {
766    match nibble {
767        0..=9 => char::from(b'0' + nibble),
768        10..=15 => char::from(b'A' + (nibble - 10)),
769        _ => '?',
770    }
771}