Skip to main content

context7_cli/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Crate-level lints: documentation hygiene and rustdoc correctness.
3#![warn(missing_docs)]
4#![warn(missing_debug_implementations)]
5#![warn(rustdoc::broken_intra_doc_links)]
6#![warn(rustdoc::private_intra_doc_links)]
7//! context7-cli library crate.
8//!
9//! Exposes the public module hierarchy and the top-level [`run`] entry point
10//! used by the binary crate in `src/main.rs`.
11//!
12//! # Module overview
13//!
14//! | Module | Responsibility |
15//! |---|---|
16//! | [`errors`] | Structured error types ([`errors::Context7Error`]) |
17//! | [`i18n`] | Bilingual i18n (EN/PT) — [`i18n::Message`] variants and [`i18n::t`] lookup |
18//! | [`storage`] | XDG config storage, four-layer key hierarchy, `keys` subcommand operations |
19//! | [`api`] | HTTP client, retry-with-rotation, Context7 API calls and response types |
20//! | [`output`] | All terminal output — the **only** module allowed to use `println!` |
21//! | [`cli`] | Clap structs, subcommand dispatchers |
22
23pub mod api;
24pub mod cli;
25pub mod errors;
26pub mod health;
27pub mod i18n;
28pub mod output;
29pub mod platform;
30pub mod storage;
31
32use anyhow::{Context, Result};
33use clap::{CommandFactory, Parser};
34use std::path::PathBuf;
35use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
36
37use cli::{Cli, Command};
38
39// ─── LOGGING ─────────────────────────────────────────────────────────────────
40
41/// Wraps the `WorkerGuard` from `tracing-appender`.
42///
43/// **Must** be kept alive until the end of `main()` to guarantee that the
44/// non-blocking log writer flushes its buffer before the process exits.
45pub struct LogGuard(#[allow(dead_code)] tracing_appender::non_blocking::WorkerGuard);
46
47impl std::fmt::Debug for LogGuard {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.debug_tuple("LogGuard").finish()
50    }
51}
52
53/// Initialises dual logging: terminal (stderr with ANSI) and log file.
54///
55/// Deletes the previous log file before starting (rotation-by-deletion).
56/// Returns [`LogGuard`] — the caller **must** keep it alive until exit.
57pub fn init_logging() -> Result<LogGuard> {
58    const BINARY_NAME: &str = env!("CARGO_PKG_NAME");
59
60    // Attempt XDG state/log directory; fall back to relative `logs/`
61    let logs_dir = storage::discover_xdg_log_paths().unwrap_or_else(|| {
62        let compile_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
63        if compile_root.join("Cargo.toml").exists() {
64            compile_root.join("logs")
65        } else {
66            PathBuf::from("logs")
67        }
68    });
69
70    let log_path = logs_dir.join(format!("{}.log", BINARY_NAME));
71
72    // Rotation by deletion: remove previous log before initialising
73    if log_path.exists() {
74        std::fs::remove_file(&log_path)
75            .with_context(|| format!("Failed to delete previous log: {}", log_path.display()))?;
76    }
77
78    std::fs::create_dir_all(&logs_dir)
79        .with_context(|| format!("Failed to create logs directory: {}", logs_dir.display()))?;
80
81    let appender_arquivo =
82        tracing_appender::rolling::never(&logs_dir, format!("{}.log", BINARY_NAME));
83    let (non_blocking_writer, guard) = tracing_appender::non_blocking(appender_arquivo);
84
85    // Respect RUST_LOG; otherwise default to "context7=info"
86    let filtro = EnvFilter::try_from_default_env()
87        .unwrap_or_else(|_| EnvFilter::new(format!("{}=info", BINARY_NAME)));
88
89    let terminal_layer = tracing_subscriber::fmt::layer()
90        .with_ansi(true)
91        .with_target(false)
92        .with_writer(std::io::stderr);
93
94    let file_layer = tracing_subscriber::fmt::layer()
95        .with_ansi(false)
96        .with_target(true)
97        .with_writer(non_blocking_writer);
98
99    tracing_subscriber::registry()
100        .with(filtro)
101        .with(terminal_layer)
102        .with(file_layer)
103        .init();
104
105    Ok(LogGuard(guard))
106}
107
108// ─── ENTRY POINT ─────────────────────────────────────────────────────────────
109
110/// Main library entry point called from `src/main.rs`.
111///
112/// Parses CLI arguments, initialises the i18n language setting, then
113/// dispatches to the appropriate subcommand handler.
114/// Returns `Ok(())` on success or propagates any `anyhow::Error`.
115///
116/// # Errors
117///
118/// Returns an [`anyhow::Error`] wrapping any structured
119/// [`crate::errors::Context7Error`] or runtime failure. The binary
120/// converts the error into a BSD-style exit code via
121/// [`crate::errors::Context7Error::exit_code`].
122pub async fn run() -> Result<()> {
123    let args = Cli::parse();
124
125    // Resolve and lock the UI language as early as possible so every
126    // downstream call to `i18n::t()` sees a consistent language.
127    let language = i18n::resolve_language(args.lang.as_deref());
128    i18n::set_language(language);
129
130    // Respect colour conventions: NO_COLOR (any value) disables
131    if std::env::var("NO_COLOR").is_ok() {
132        colored::control::set_override(false);
133    }
134    // CLICOLOR_FORCE=1 forces colours even in pipes
135    if std::env::var("CLICOLOR_FORCE")
136        .map(|v| v == "1")
137        .unwrap_or(false)
138    {
139        colored::control::set_override(true);
140    }
141    // CLI flags have priority over env vars
142    if args.no_color || args.json || args.plain {
143        colored::control::set_override(false);
144    }
145    // Suppress stdout when --quiet is passed
146    output::set_quiet(args.quiet);
147
148    tokio::select! {
149        result = async {
150            match args.command {
151                Command::Keys { operation } => cli::run_keys(operation, args.json),
152
153                Command::Library { name, query } => cli::run_library(name, query, args.json).await,
154
155                Command::Docs {
156                    library_id,
157                    query,
158                    text,
159                } => cli::run_docs(library_id, query, text, args.json).await,
160
161                Command::Completions { shell } => {
162                    clap_complete::generate(
163                        shell,
164                        &mut cli::Cli::command(),
165                        "context7",
166                        &mut std::io::stdout(),
167                    );
168                    Ok(())
169                }
170
171                Command::Health => {
172                    let code = health::run_health(args.json).await?;
173                    if code != 0 {
174                        std::process::exit(code);
175                    }
176                    Ok(())
177                }
178            }
179        } => result,
180        _ = tokio::signal::ctrl_c() => {
181            tracing::warn!("Interrupted by user (Ctrl+C)");
182            std::process::exit(130)
183        }
184    }
185}
186
187// ─── TESTS ───────────────────────────────────────────────────────────────────
188
189#[cfg(test)]
190mod tests {
191    /// Smoke test: verify that the Duration import from tokio::time compiles correctly.
192    /// This guards against accidental removal of tokio::time re-exports.
193    #[test]
194    fn test_duration_available() {
195        let _ = tokio::time::Duration::from_millis(500);
196    }
197}