├── .gitignore ├── rust-toolchain.toml ├── .cargo └── config.toml ├── src ├── ui │ ├── mod.rs │ ├── guard.rs │ ├── event.rs │ ├── logging.rs │ ├── toc_panel.rs │ └── app.rs ├── lib.rs ├── client.rs ├── main.rs └── cache.rs ├── rustfmt.toml ├── dist-workspace.toml ├── LICENSE ├── Cargo.toml ├── README.md ├── CHANGELOG.md ├── .github └── workflows │ └── release.yml └── Cargo.lock /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "stable" -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [alias] 2 | cl = "clippy" 3 | semver-head = "semver-checks check-release --baseline-rev HEAD~1" 4 | set-ver = "set-version --bump" 5 | update-deps = "upgrade" 6 | depall = "depgraph --all-deps --dedup-transitive-deps" 7 | 8 | 9 | [build] 10 | rustflags = ["-C", "target-cpu=native"] 11 | -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | //! Core UI components for the RFC Reader application. 2 | //! 3 | //! Provides rendering, event handling, and state management for the 4 | //! terminal-based user interface. 5 | mod app; 6 | mod event; 7 | pub mod guard; 8 | pub mod logging; 9 | mod toc_panel; 10 | 11 | pub use app::{App, AppMode, AppStateFlags}; 12 | pub use event::{Event, EventHandler}; 13 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # use `cargo +nightly fmt` to format 2 | unstable_features = true 3 | 4 | binop_separator = "Back" 5 | brace_style = "AlwaysNextLine" 6 | control_brace_style = "AlwaysNextLine" 7 | combine_control_expr = false 8 | chain_width = 45 9 | 10 | wrap_comments = true 11 | comment_width = 80 12 | 13 | format_macro_bodies = true 14 | format_macro_matchers = true 15 | format_strings = true 16 | 17 | group_imports = "StdExternalCrate" 18 | imports_granularity = "Module" 19 | 20 | empty_item_single_line = false 21 | 22 | match_block_trailing_comma = true 23 | 24 | max_width = 80 -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! A library for reading RFC documents in the terminal. 2 | //! 3 | //! Provides functionality for fetching, caching, and rendering RFCs with a 4 | //! rich terminal user interface. 5 | //! 6 | //! # Features 7 | //! 8 | //! - Fetching RFCs from the official RFC Editor website. 9 | //! - Local caching of RFCs to minimize network requests. 10 | //! - Terminal UI with navigation, search, and table of contents. 11 | //! 12 | //! # Modules 13 | //! 14 | //! - `client`: HTTP client for remote RFC fetching. 15 | //! - `cache`: Local storage for performance improvement. 16 | //! - `ui`: Terminal user interface components and event handling. 17 | pub mod cache; 18 | pub mod client; 19 | pub mod ui; 20 | 21 | pub use ui::logging; 22 | -------------------------------------------------------------------------------- /dist-workspace.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["cargo:."] 3 | 4 | # Config for 'dist' 5 | [dist] 6 | # The preferred dist version to use in CI (Cargo.toml SemVer syntax) 7 | cargo-dist-version = "0.30.0" 8 | # CI backends to support 9 | ci = "github" 10 | # The installers to generate for each app 11 | installers = ["shell", "powershell"] 12 | # Target platforms to build apps for (Rust target-triple syntax) 13 | targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] 14 | # Path that installers should place binaries in 15 | install-path = "CARGO_HOME" 16 | # Whether to install an updater program 17 | install-updater = false 18 | 19 | [dist.github-custom-runners] 20 | aarch64-apple-darwin = "macos-15-intel" 21 | x86_64-apple-darwin = "macos-15-intel" 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Ozan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rfc_reader" 3 | version = "0.11.2" 4 | rust-version = "1.88.0" 5 | edition = "2024" 6 | description = "A TUI based RFC viewer" 7 | repository = "https://github.com/ozan2003/rfc_reader" 8 | keywords = [ 9 | "rust", 10 | "caching", 11 | "viewer", 12 | "table-of-contents", 13 | "tui", 14 | "argument-parsing", 15 | "offline-capable", 16 | "text-search", 17 | "http-request", 18 | "rfcs", 19 | "reader-ui", 20 | "request-for-comments", 21 | "command-line", 22 | "ratatui", 23 | ] 24 | license-file = "LICENSE" 25 | 26 | 27 | [dependencies] 28 | anyhow = "1.0.100" 29 | bitflags = "2.10.0" 30 | cached = "0.56.0" 31 | clap = { version = "4.5.53", features = ["cargo"] } 32 | # align with ratatui's crossterm version to not compile crossterm twice 33 | # i'm only using event handling, no need for the serde, event-stream stuff 34 | crossterm = { version = "=0.28.1", default-features = false, features = [ 35 | "events", 36 | ] } 37 | directories = "6.0.0" 38 | env_logger = "0.11.8" 39 | file-rotate = "0.8.0" 40 | log = "0.4.29" 41 | ratatui = "0.29.0" 42 | regex = "1.12.2" 43 | textwrap = "0.16.2" 44 | # remove rustls from dependencies since im using native-tls anyway 45 | ureq = { version = "3.1.4", default-features = false, features = [ 46 | "native-tls", 47 | ] } 48 | 49 | [dev-dependencies] 50 | tempfile = "3.23.0" 51 | 52 | [lints.clippy] 53 | correctness = { level = "forbid", priority = -1 } 54 | perf = { level = "forbid", priority = -2 } 55 | pedantic = { level = "deny", priority = -3 } 56 | style = { level = "warn", priority = -4 } 57 | complexity = { level = "warn", priority = -5 } 58 | suspicious = { level = "warn", priority = -6 } 59 | 60 | redundant-clone = { level = "warn", priority = -4 } 61 | derive-partial-eq-without-eq = { level = "warn", priority = -5 } 62 | unnecessary-struct-initialization = { level = "warn", priority = -6 } 63 | redundant-pub-crate = { level = "warn", priority = -7 } 64 | missing-const-for-fn = { level = "deny", priority = -8 } 65 | undocumented_unsafe_blocks = { level = "deny", priority = -8 } 66 | branches_sharing_code = { level = "warn", priority = -9 } 67 | uninlined-format-args = "allow" 68 | 69 | arithmetic_side_effects = { level = "warn", priority = 0 } 70 | unchecked_time_subtraction = { level = "deny", priority = 0 } 71 | 72 | # Misc 73 | multiple_unsafe_ops_per_block = { level = "warn", priority = 0 } 74 | unwrap_used = { level = "warn", priority = 0 } # Prefer ? or expect 75 | lossy_float_literal = { level = "warn", priority = 0 } 76 | float_cmp = { level = "warn", priority = 0 } 77 | float_cmp_const = { level = "warn", priority = 0 } 78 | while_float = { level = "warn", priority = 0 } 79 | string_slice = { level = "warn", priority = 0 } 80 | get_unwrap = { level = "warn", priority = 0 } 81 | mem_forget = { level = "warn", priority = 0 } 82 | use_self = { level = "warn", priority = 0 } 83 | useless_let_if_seq = { level = "warn", priority = 0 } 84 | str_to_string = { level = "warn", priority = 0 } 85 | 86 | # The profile that 'dist' will build with 87 | [profile.dist] 88 | inherits = "release" 89 | lto = "thin" 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RFC Reader 2 | 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) 4 | ![Rust](https://img.shields.io/badge/language-Rust-orange?logo=rust) 5 | [![Stars](https://img.shields.io/github/stars/ozan2003/rfc_reader)](https://github.com/ozan2003/rfc_reader/stargazers) 6 | [![Last Commit](https://img.shields.io/github/last-commit/ozan2003/rfc_reader)](https://github.com/ozan2003/rfc_reader/commits/master) 7 | [![Code Size](https://img.shields.io/github/languages/code-size/ozan2003/rfc_reader)](https://github.com/ozan2003/rfc_reader) 8 | [![dependency status](https://deps.rs/repo/github/ozan2003/rfc_reader/status.svg?path=.)](https://deps.rs/repo/github/ozan2003/rfc_reader?path=.) 9 | ![Built With Ratatui](https://img.shields.io/badge/Built_With_Ratatui-000?logo=ratatui&logoColor=fff) 10 | 11 | 12 | A tool to read IETF RFCs (Request for Comments) with a TUI, allowing you to fetch, cache, and browse them. 13 | 14 | ## Features 15 | 16 | - View documents directly in the terminal 17 | - Automatic caching of the documents for offline reading 18 | - Text search functionality within document 19 | - Table of contents navigation 20 | 21 | > [!NOTE] 22 | > Table of contents section might not always be accurate, as there's no standard way to extract it from RFCs. It works best with RFCs that have a well-defined TOC. 23 | 24 | - Keyboard controls 25 | 26 | ## Screenshots 27 | 28 | [![rfc-reader-normal.png](https://i.postimg.cc/VvwfYKFm/rfc-reader-normal.png)](https://postimg.cc/njdb2Y5P) 29 | 30 | [![rfc-reader-toc.png](https://i.postimg.cc/k5kMHJ6V/rfc-reader-toc.png)](https://postimg.cc/DWPKJKcF) 31 | 32 | [![rfc-reader-search.png](https://i.postimg.cc/nzBVbyB3/rfc-reader-search.png)](https://postimg.cc/tZRGFmr6) 33 | 34 | ## Usage 35 | 36 | ```bash 37 | rfc_reader [OPTIONS] [RFC_NUMBER] 38 | ``` 39 | 40 | ### Examples 41 | 42 | ```bash 43 | # Read a specific RFC 44 | rfc_reader 2616 45 | 46 | # Read a specific RFC in offline mode (only works if previously cached) 47 | rfc_reader --offline 2616 48 | 49 | # Clear the RFC cache 50 | rfc_reader --clear-cache 51 | ``` 52 | 53 | ### Options 54 | 55 | - `--offline`, `-o`: Run in offline mode (only load cached RFCs) 56 | - `--clear-cache`: Clear the RFC cache 57 | 58 | Refer to `rfc_reader --help` for more options. 59 | 60 | ## Controls 61 | 62 | Refer to the [wiki](https://github.com/ozan2003/rfc_reader/wiki/Keybindings) for keybindings. 63 | 64 | ## Minimum Supported Rust Version (MSRV) 65 | 66 | Rust 1.88.0 or newer. 67 | 68 | ## Cache Location 69 | 70 | RFCs are cached locally to improve performance and enable offline reading. 71 | 72 | Linux: 73 | 74 | ```bash 75 | /home/{YOUR_USERNAME}/.config/rfc_reader 76 | ``` 77 | 78 | MacOS: 79 | 80 | ```bash 81 | /Users/{YOUR_USERNAME}/Library/Application Support/rfc_reader 82 | ``` 83 | 84 | Windows: 85 | 86 | ```bash 87 | C:\Users\{YOUR_USERNAME}\AppData\Roaming\rfc_reader\config 88 | ``` 89 | 90 | ## Contributing 91 | 92 | I don't know very well about contribution/PR stuff. Contact me or create a issue if for any issues or suggestions. 93 | 94 | ## License 95 | 96 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 97 | -------------------------------------------------------------------------------- /src/client.rs: -------------------------------------------------------------------------------- 1 | //! RFC client for fetching documents. 2 | //! 3 | //! Manages network requests to the RFC Editor's website. 4 | use std::io::Read; 5 | use std::time::Duration; 6 | 7 | use anyhow::{Context, Result}; 8 | use log::debug; 9 | use ureq::Agent; 10 | use ureq::config::Config; 11 | use ureq::tls::{TlsConfig, TlsProvider}; 12 | 13 | const RFC_BASE_URL: &str = "https://www.rfc-editor.org/rfc/rfc"; 14 | const RFC_INDEX_URL: &str = "https://www.rfc-editor.org/rfc-index.txt"; 15 | 16 | /// Client for fetching RFCs 17 | /// 18 | /// This client is used to fetch RFCs from the RFC Editor's website. 19 | /// It is responsible for fetching the RFC index and RFCs. 20 | pub struct RfcClient 21 | { 22 | client: Agent, 23 | } 24 | 25 | impl RfcClient 26 | { 27 | /// Create a new RFC client. 28 | /// 29 | /// # Returns 30 | /// 31 | /// A new RFC client. 32 | /// 33 | /// # Panics 34 | /// 35 | /// Panics if the HTTP client cannot be created. 36 | #[must_use] 37 | pub fn new(duration: Duration) -> Self 38 | { 39 | let config = Config::builder() 40 | .timeout_global(Some(duration)) 41 | .tls_config( 42 | TlsConfig::builder() 43 | .provider(TlsProvider::NativeTls) 44 | .build(), 45 | ) 46 | .build(); 47 | 48 | Self { 49 | client: config.new_agent(), 50 | } 51 | } 52 | 53 | /// Fetch a specific RFC. 54 | /// 55 | /// # Arguments 56 | /// 57 | /// * `rfc_number` - The number of the RFC to fetch. 58 | /// 59 | /// # Returns 60 | /// 61 | /// The RFC content as a text. 62 | /// 63 | /// # Errors 64 | /// 65 | /// Returns an error if the RFC is not found or unavailable. 66 | pub fn fetch_rfc(&self, rfc_number: u16) -> Result> 67 | { 68 | // RFC documents are available in TXT format 69 | let rfc_url = format!("{RFC_BASE_URL}{rfc_number}.txt"); 70 | 71 | let response = self 72 | .client 73 | .get(rfc_url) 74 | .call() 75 | .with_context(|| format!("Failed to fetch RFC {rfc_number}"))?; 76 | 77 | debug!("Got response: {response:?}"); 78 | 79 | let mut response_body = String::new(); 80 | response 81 | .into_body() 82 | .into_reader() 83 | .read_to_string(&mut response_body) 84 | .with_context(|| { 85 | format!("Failed to read RFC {rfc_number} content") 86 | })?; 87 | 88 | Ok( 89 | // Remove the unnecesary form feed. 90 | response_body 91 | .trim() 92 | .replace('\x0c', "") 93 | .into_boxed_str(), 94 | ) 95 | } 96 | 97 | /// Fetch the RFC index. 98 | /// 99 | /// # Returns 100 | /// 101 | /// The RFC index as a text. 102 | /// 103 | /// # Errors 104 | /// 105 | /// Returns an error if the RFC index is not available or if the request 106 | /// fails. 107 | pub fn fetch_rfc_index(&self) -> Result> 108 | { 109 | let response = self 110 | .client 111 | .get(RFC_INDEX_URL) 112 | .call() 113 | .context("Failed to fetch RFC index")?; 114 | 115 | debug!("Got response: {response:?}"); 116 | 117 | let mut response_body = String::new(); 118 | response 119 | .into_body() 120 | .into_reader() 121 | .read_to_string(&mut response_body) 122 | .context("Failed to read RFC index content")?; 123 | 124 | Ok(response_body.into_boxed_str()) 125 | } 126 | } 127 | 128 | impl Default for RfcClient 129 | { 130 | fn default() -> Self 131 | { 132 | Self::new(Duration::from_secs(30)) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/ui/guard.rs: -------------------------------------------------------------------------------- 1 | //! Provides a RAII guard for safe terminal lifecycle management. 2 | //! 3 | //! This module uses the RAII (Resource Acquisition Is Initialization) 4 | //! pattern to manage the terminal state. 5 | //! 6 | //! A guard object is created to initialize the TUI, 7 | //! and its `Drop` implementation automatically restores the terminal when it 8 | //! goes out of scope, either on normal exit or during a panic unwind. 9 | use std::io::stdout; 10 | use std::panic::{set_hook, take_hook}; 11 | 12 | use anyhow::Result; 13 | use crossterm::ExecutableCommand; 14 | use crossterm::cursor::{SetCursorStyle, Show}; 15 | use crossterm::terminal::{ 16 | EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, 17 | enable_raw_mode, 18 | }; 19 | use log::error; 20 | use ratatui::Terminal; 21 | use ratatui::backend::{Backend as RatatuiBackend, CrosstermBackend}; 22 | 23 | /// RAII wrapper for terminal state. 24 | /// 25 | /// Manages the terminal's configuration, ensuring it is always returned 26 | /// to its original state when this struct is dropped. 27 | pub struct TerminalGuard; 28 | 29 | impl TerminalGuard 30 | { 31 | /// Creates a `TerminalGuard` for TUI setup. 32 | /// 33 | /// Configures the terminal by entering raw mode and switching to the 34 | /// alternate screen buffer. 35 | /// 36 | /// # Returns 37 | /// 38 | /// The `TerminalGuard`. Holding this instance guarantees terminal 39 | /// restoration upon its drop. 40 | /// 41 | /// # Errors 42 | /// 43 | /// On failure to enter raw mode or switch screens. 44 | pub fn new() -> Result 45 | { 46 | // Setup terminal and cursor 47 | enable_raw_mode()?; 48 | stdout().execute(SetCursorStyle::BlinkingBar)?; 49 | stdout().execute(EnterAlternateScreen)?; 50 | Ok(Self) 51 | } 52 | } 53 | 54 | impl Drop for TerminalGuard 55 | { 56 | /// Restores the terminal state. 57 | /// 58 | /// Automatically called on `TerminalGuard` drop. 59 | /// 60 | /// Exits raw mode and 61 | /// returns to the main screen, ensuring a clean terminal state. 62 | fn drop(&mut self) 63 | { 64 | // Restore the cursor to visible and default style 65 | if let Err(err) = stdout().execute(Show) 66 | { 67 | error!("Failed to show cursor on drop: {err}"); 68 | } 69 | 70 | if let Err(err) = stdout().execute(SetCursorStyle::DefaultUserShape) 71 | { 72 | error!("Failed to reset cursor style: {err}"); 73 | } 74 | 75 | // Terminal will be borked when failure, at least inform the user 76 | if let Err(err) = disable_raw_mode() 77 | { 78 | error!("Failed to disable raw mode: {err}"); 79 | } 80 | 81 | if let Err(err) = stdout().execute(LeaveAlternateScreen) 82 | { 83 | error!("Failed to leave alternate screen: {err}"); 84 | } 85 | } 86 | } 87 | 88 | /// Initialize the terminal 89 | /// 90 | /// This creates a new terminal and returns it. 91 | /// 92 | /// # Returns 93 | /// 94 | /// Returns the terminal. 95 | /// 96 | /// # Errors 97 | /// 98 | /// Returns an error if the terminal fails to enter raw mode or leave 99 | /// alternate screen. 100 | pub fn init_tui() -> Result> 101 | { 102 | // Terminal setup is now handled by TerminalGuard 103 | // We just create and return the terminal 104 | let backend = CrosstermBackend::new(stdout()); 105 | // use ? to coerce and return an appropriate `Err` 106 | // wrap the resulting value in `Ok` to return `anyhow::Result` 107 | Ok(Terminal::new(backend)?) 108 | } 109 | 110 | /// Initialize the panic hook to handle panics 111 | /// 112 | /// # Panics 113 | /// 114 | /// This will panic if the terminal fails to enter raw mode or leave alternate 115 | /// screen. 116 | pub fn init_panic_hook() 117 | { 118 | let original_hook = take_hook(); 119 | set_hook(Box::new(move |panic_info| { 120 | // Restore terminal to normal state without panicking 121 | disable_raw_mode().expect("Failed to disable raw mode"); 122 | stdout() 123 | .execute(LeaveAlternateScreen) 124 | .expect("Failed to leave alternate screen"); 125 | 126 | error!("Application panicked: {panic_info}"); 127 | 128 | // Call the original panic hook 129 | original_hook(panic_info); 130 | })); 131 | } 132 | -------------------------------------------------------------------------------- /src/ui/event.rs: -------------------------------------------------------------------------------- 1 | //! Provides non-blocking application event handling. 2 | //! 3 | //! Runs an event listener on a separate thread, forwarding all events 4 | //! to the main application via a channel. 5 | use std::sync::mpsc; 6 | use std::thread::{self, JoinHandle}; 7 | use std::time::{Duration, Instant}; 8 | 9 | use anyhow::{Context, Result}; 10 | use crossterm::event::{self, Event as CrosstermEvent, KeyEvent}; 11 | 12 | /// Events that can be processed by the application 13 | #[derive(Debug, Clone, Copy)] 14 | pub enum Event 15 | { 16 | /// Regular time tick for updating UI elements 17 | Tick, 18 | /// Keyboard input event 19 | Key(KeyEvent), 20 | /// Terminal resize event with new dimensions 21 | Resize(u16, u16), 22 | } 23 | 24 | /// Handles terminal events 25 | /// 26 | /// Manages event handling in a separate thread and provides 27 | /// a way to receive events through a channel. 28 | pub struct EventHandler 29 | { 30 | /// Receiver side of the event channel to get events from the handler 31 | /// thread 32 | event_receiver: mpsc::Receiver, 33 | /// Sender for shutdown the thread for graceful shutdown 34 | // The receiver is moved to the thread 35 | shutdown_sender: mpsc::Sender<()>, 36 | /// Handle to keep the thread alive 37 | // Option is used to move the handle in `drop` 38 | // since we can't move the handle out of the `&mut self` 39 | // for calling `join` in `drop` 40 | thread_handle: Option>, 41 | } 42 | 43 | impl EventHandler 44 | { 45 | /// Maximum amount of time spent waiting inside `event::poll` so that 46 | /// shutdown signals are observed promptly. 47 | const MAX_POLL_WAIT: Duration = Duration::from_millis(50); 48 | 49 | /// Creates a new event handler with the specified tick rate 50 | /// 51 | /// # Arguments 52 | /// 53 | /// * `tick_rate` - The duration between tick events 54 | /// 55 | /// # Returns 56 | /// 57 | /// A new `EventHandler` instance with a running background thread 58 | /// 59 | /// # Panics 60 | /// 61 | /// Panics if the channel is disconnected. 62 | #[must_use] 63 | pub fn new(tick_rate: Duration) -> Self 64 | { 65 | // Create a channel for sending events from the thread to the main 66 | // application 67 | let (event_sender, event_receiver) = mpsc::channel(); 68 | let (shutdown_sender, shutdown_receiver) = mpsc::channel(); 69 | 70 | // Spawn a thread that continuously polls for terminal events 71 | // Move the `shutdown_receiver` to the thread. 72 | let handle = thread::spawn(move || { 73 | let mut last_tick = Instant::now(); 74 | 75 | loop 76 | { 77 | // Check for shutdown signal from the `shutdown_receiver` 78 | if shutdown_receiver.try_recv().is_ok() 79 | { 80 | break; 81 | } 82 | 83 | // Calculate how long to wait before the next tick 84 | // but don't wait longer than `MAX_POLL_WAIT` to ensure 85 | // shutdown signals are observed promptly. 86 | let timeout = tick_rate 87 | .saturating_sub(last_tick.elapsed()) 88 | .min(Self::MAX_POLL_WAIT); 89 | 90 | // Poll for crossterm events, with timeout to ensure we generate 91 | // tick events 92 | match event::poll(timeout) 93 | { 94 | Ok(true) => 95 | { 96 | match event::read() 97 | { 98 | Ok(CrosstermEvent::Key(key)) => 99 | { 100 | if event_sender.send(Event::Key(key)).is_err() 101 | { 102 | break; 103 | } 104 | }, 105 | Ok(CrosstermEvent::Resize(width, height)) => 106 | { 107 | if event_sender 108 | .send(Event::Resize(width, height)) 109 | .is_err() 110 | { 111 | break; 112 | } 113 | }, 114 | // Ignore other events 115 | Ok(_) => 116 | {}, 117 | Err(err) => 118 | { 119 | eprintln!( 120 | "Failed to read terminal event: {err}" 121 | ); 122 | break; 123 | }, 124 | } 125 | }, 126 | Ok(false) => 127 | {}, 128 | Err(err) => 129 | { 130 | eprintln!("Failed to poll terminal events: {err}"); 131 | break; 132 | }, 133 | } 134 | 135 | // Generate tick events for animations and regular updates 136 | if last_tick.elapsed() >= tick_rate 137 | { 138 | if event_sender.send(Event::Tick).is_err() 139 | { 140 | break; 141 | } 142 | last_tick = Instant::now(); 143 | } 144 | } 145 | }); 146 | 147 | Self { 148 | event_receiver, 149 | shutdown_sender, 150 | thread_handle: Some(handle), 151 | } 152 | } 153 | 154 | /// Gets the next event from the event channel 155 | /// 156 | /// This method blocks until an event is available 157 | /// 158 | /// # Returns 159 | /// 160 | /// The next event, or an error if the channel is disconnected 161 | /// 162 | /// # Errors 163 | /// 164 | /// Returns an error if the channel is disconnected. 165 | pub fn next(&self) -> Result 166 | { 167 | self.event_receiver 168 | .recv() 169 | .context("Event channel disconnected") 170 | } 171 | } 172 | 173 | impl Drop for EventHandler 174 | { 175 | fn drop(&mut self) 176 | { 177 | // Signal shutdown (ignore if already closed) 178 | // Don't panic, so assign the entire result. 179 | let _ = self.shutdown_sender.send(()); 180 | 181 | // Wait for thread to finish 182 | if let Some(handle) = self.thread_handle.take() 183 | { 184 | let _ = handle.join(); 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/ui/logging.rs: -------------------------------------------------------------------------------- 1 | //! Provides application logging utilities. 2 | //! 3 | //! Handles the initialization and configuration of the application's 4 | //! logging system. 5 | //! 6 | //! ## File Rotation 7 | //! 8 | //! - The log files are rotated as `.log.`. 9 | //! - Log files exceeding 5 MiB are rotated. 10 | //! - Uncompressed log files are kept for at most `UNCOMPRESSED_LOG_FILE_COUNT` 11 | //! files. 12 | //! - Compressed log files are deleted when the number of log files exceeds 13 | //! `MAX_LOG_FILE_COUNT`. 14 | use std::fs::{self, OpenOptions}; 15 | use std::io::Write; 16 | use std::path::Path; 17 | use std::sync::LazyLock; 18 | 19 | use anyhow::{Context, Result, bail}; 20 | use directories::BaseDirs; 21 | use env_logger::{Builder, Target}; 22 | use file_rotate::compression::Compression; 23 | use file_rotate::suffix::AppendCount; 24 | use file_rotate::{ContentLimit, FileRotate}; 25 | use log::LevelFilter; 26 | 27 | /// Maximum size of a log file in bytes. 28 | const LOG_FILE_SIZE: usize = 5 * 1024 * 1024; // 5 MiB 29 | /// Maximum number of log files to keep. 30 | const MAX_LOG_FILE_COUNT: usize = 5; 31 | /// Maximum number of uncompressed log files to keep. 32 | const UNCOMPRESSED_LOG_FILE_COUNT: usize = 2; 33 | 34 | /// This is directory where the log files will be stored. 35 | static LOG_FILES_PATH: LazyLock> = LazyLock::new(|| { 36 | let base_dirs = 37 | BaseDirs::new().expect("Failed to determine base directories"); 38 | 39 | let base_path = if cfg!(target_os = "linux") 40 | { 41 | // SAFETY: This block is only executed if we are using Linux, 42 | // as the function only returns 'Some' here. 43 | unsafe { 44 | // Use `$XDG_STATE_HOME` on linux as its stated 45 | // on XDG Base Directory Specification. 46 | base_dirs.state_dir().unwrap_unchecked() 47 | } 48 | } 49 | else 50 | { 51 | base_dirs.data_local_dir() 52 | }; 53 | 54 | fs::create_dir_all(base_path).expect("Failed to create base directory"); 55 | 56 | // Use a dedicated directory for logs 57 | let logs_dir_path = base_path 58 | .join(env!("CARGO_PKG_NAME")) 59 | .join("logs"); 60 | 61 | fs::create_dir_all(&logs_dir_path).expect("Failed to create log directory"); 62 | 63 | logs_dir_path.into_boxed_path() 64 | }); 65 | 66 | /// Base log file path inside the logs directory. 67 | /// 68 | /// Formatted as: `.log` 69 | static BASE_LOG_FILE_PATH: LazyLock> = LazyLock::new(|| { 70 | get_log_files_dir_path() 71 | .join(concat!(env!("CARGO_PKG_NAME"), ".log")) 72 | .into_boxed_path() 73 | }); 74 | 75 | /// Returns the path to the directory where the log files are stored. 76 | /// 77 | /// # Returns 78 | /// 79 | /// A static `Path` reference to the log files directory path. 80 | #[must_use] 81 | pub fn get_log_files_dir_path() -> &'static Path 82 | { 83 | &LOG_FILES_PATH 84 | } 85 | 86 | /// Returns the path to the log file. 87 | /// 88 | /// # Returns 89 | /// 90 | /// A static `Path` reference to the log file path. 91 | #[must_use] 92 | fn get_base_log_file_path() -> &'static Path 93 | { 94 | &BASE_LOG_FILE_PATH 95 | } 96 | 97 | /// Initializes the logging system for the application. 98 | /// 99 | /// This function sets up the logging configuration, including the 100 | /// log file path, log level, and log format. 101 | /// 102 | /// # Errors 103 | /// 104 | /// Returns an error if the logging system cannot be initialized. 105 | pub fn init_logging() -> Result<()> 106 | { 107 | // static assertion to prevent skill issues in the future 108 | const { 109 | assert!( 110 | UNCOMPRESSED_LOG_FILE_COUNT < MAX_LOG_FILE_COUNT, 111 | "How can we compress more file than we have right now?" 112 | ); 113 | } 114 | 115 | let base_log_file_path = get_base_log_file_path(); 116 | 117 | let log_open_option = { 118 | let mut option = OpenOptions::new(); 119 | option.read(true).create(true).append(true); 120 | 121 | option 122 | }; 123 | 124 | // Files are rotated as `.log.` 125 | let rotator = FileRotate::new( 126 | base_log_file_path, 127 | AppendCount::new(MAX_LOG_FILE_COUNT), 128 | ContentLimit::Bytes(LOG_FILE_SIZE), 129 | Compression::OnRotate(UNCOMPRESSED_LOG_FILE_COUNT), 130 | Some(log_open_option), 131 | ); 132 | 133 | let mut builder = Builder::new(); 134 | builder 135 | .filter_level(LevelFilter::Info) 136 | .filter_module(env!("CARGO_PKG_NAME"), LevelFilter::Debug) 137 | .parse_default_env() 138 | .format(|buf, record| { 139 | let ts = buf.timestamp_millis(); 140 | writeln!( 141 | buf, 142 | "{ts} {:<5} {}: {}", 143 | record.level(), 144 | record.target(), 145 | record.args() 146 | ) 147 | }) 148 | .target(Target::Pipe(Box::new(rotator))); 149 | 150 | builder 151 | .try_init() 152 | .context("Failed to initialize logger") 153 | } 154 | 155 | /// Removes the log files. 156 | /// 157 | /// # Returns 158 | /// 159 | /// Returns `Ok(())` if the files were successfully removed or didn't exist. 160 | /// Returns an error if the files exist but couldn't be removed. 161 | /// 162 | /// # Errors 163 | /// 164 | /// Returns an error if the files exist but couldn't be removed. 165 | pub fn clear_log_files() -> Result<()> 166 | { 167 | let log_files_path = get_log_files_dir_path(); 168 | 169 | if !log_files_path.exists() 170 | { 171 | return Ok(()); 172 | } 173 | 174 | let Some(base_log_name) = get_base_log_file_path() 175 | .file_name() 176 | .and_then(|s| s.to_str()) 177 | else 178 | { 179 | bail!("Failed to get log file name"); 180 | }; 181 | 182 | for entry in fs::read_dir(log_files_path) 183 | .context("Failed to read log files directory")? 184 | { 185 | let path = entry?.path(); 186 | 187 | if !path.is_file() 188 | { 189 | continue; 190 | } 191 | 192 | let Some(name) = path.file_name().and_then(|s| s.to_str()) 193 | else 194 | { 195 | continue; 196 | }; 197 | 198 | if name == base_log_name || 199 | name.strip_prefix(base_log_name) 200 | .is_some_and(|s| s.starts_with('.')) 201 | { 202 | fs::remove_file(path).context("Failed to remove log file")?; 203 | } 204 | } 205 | 206 | // Remove logs directory if empty, then app directory if it also 207 | // became empty. 208 | if fs::remove_dir(log_files_path).is_ok() && 209 | let Some(app_dir) = log_files_path.parent() 210 | { 211 | let _ = fs::remove_dir(app_dir); 212 | } 213 | 214 | Ok(()) 215 | } 216 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.11.2] - 2025-12-02 9 | 10 | ## Added 11 | 12 | - Introduce `MAX_POLL_WAIT` constant to improve shutdown signal 13 | responsiveness during event polling in [event.rs](src/ui/event.rs) 14 | 15 | ## [0.11.1] - 2025-11-19 16 | 17 | ## Added 18 | 19 | - Added a new test for the cache in [cache.rs](src/cache.rs) 20 | 21 | ## Changed 22 | 23 | - Use `Box` instead of `PathBuf` in [cache.rs](src/cache.rs) 24 | - Use `context` for cache initialization in [main.rs](src/main.rs) 25 | 26 | ## [0.11.0] - 2025-11-07 27 | 28 | ## Fixed 29 | 30 | - The log files directories are now removed when clearing the log files in [logging.rs](src/ui/logging.rs) 31 | 32 | ## Changed 33 | 34 | - Add additional `logs` directory to the log files directory path in [logging.rs](src/ui/logging.rs) 35 | - Change log file path to use the new `logs` directory in [logging.rs](src/ui/logging.rs) 36 | 37 | ## [0.10.0] - 2025-11-07 38 | 39 | ## Changed 40 | 41 | - Change log file path to use `$XDG_STATE_HOME` on Linux in [logging.rs](src/ui/logging.rs) 42 | - Use `&Path` instead of `PathBuf` in [logging.rs](src/ui/logging.rs) 43 | - Change log files location to local data directory on non-Linux platforms in [logging.rs](src/ui/logging.rs) 44 | 45 | ## [0.9.1] - 2025-11-07 46 | 47 | ## Changed 48 | 49 | - Change wording from "log file" to "log files" in [logging.rs](src/ui/logging.rs) and 50 | [main.rs](src/main.rs) 51 | 52 | ## [0.9.0] - 2025-11-06 53 | 54 | ## Added 55 | 56 | - Added a log file rotation mechanism with compression in [logging.rs](src/ui/logging.rs) 57 | 58 | ## [0.8.2] - 2025-11-06 59 | 60 | ## Changed 61 | 62 | - Use `Box` instead of `PathBuf` in [logging.rs](src/ui/logging.rs) 63 | 64 | ## [0.8.1] - 2025-10-29 65 | 66 | ## Added 67 | 68 | - Introduce an accessor function instead of directly using the log file path in 69 | [logging.rs](src/ui/logging.rs) 70 | 71 | ## [0.8.0] - 2025-10-14 72 | 73 | ## Changed 74 | 75 | - The search box now supports a cursor for better text editing in 76 | [app.rs](src/ui/app.rs) 77 | - Made the search box input prompt constant in [app.rs](src/ui/app.rs) 78 | 79 | ## [0.7.0] - 2025-10-10 80 | 81 | ## Added 82 | 83 | - Added support for case-sensitive and regex searches in [app.rs](src/ui/app.rs) and 84 | [main.rs](src/main.rs) 85 | 86 | ## Changed 87 | 88 | - Regexes are now cached to improve performance in [app.rs](src/ui/app.rs) 89 | 90 | ## [0.6.3] - 2025-09-28 91 | 92 | ## Added 93 | 94 | - Added reasons for lint exceptions in [main.rs](src/main.rs) 95 | 96 | ## Changed 97 | 98 | - Modified the regex for document section headings in [toc_panel.rs](src/ui/toc_panel.rs) 99 | - Replace `std::io::Result` with `anyhow::Result` for consistency in error handling 100 | in [guard.rs](src/ui/guard.rs) 101 | - Slightly refactored search logic in [app.rs](src/ui/app.rs) 102 | - Use total line count instead of the byte count of the document when 103 | scrolling the view in [main.rs](src/main.rs) 104 | 105 | ## [0.6.2] - 2025-09-05 106 | 107 | ## Added 108 | 109 | - Introduced new lints in [Cargo.toml](Cargo.toml) and applied them in 110 | [app.rs](src/ui/app.rs), [guard.rs](src/guard.rs) and [toc_panel.rs](src/ui/toc_panel.rs) 111 | 112 | ## [0.6.1] - 2025-09-04 113 | 114 | ## Changed 115 | 116 | - Refactor the ToC panel creation process in [app.rs](src/ui/app.rs) 117 | 118 | ## [0.6.0] - 2025-08-22 119 | 120 | ## Changed 121 | 122 | - Updated dependencies to their latest versions 123 | - Moved the constants to their respective methods in [app.rs](src/ui/app.rs) for better organization 124 | - Use `Box` instead of `String` for RFC content and ToC panel contents in [cache.rs](src/cache.rs), 125 | [client.rs](src/client.rs), [app.rs](src/ui/app.rs) and [toc_panel.rs](src/ui/toc_panel.rs) 126 | 127 | ## [0.5.4] - 2025-08-14 128 | 129 | ### Changed 130 | 131 | - Simplified argument parsing logic in [main.rs](src/main.rs) 132 | 133 | ## [0.5.3] - 2025-08-12 134 | 135 | ### Changed 136 | 137 | - Replace `context` with `with_context` where `format!` is used 138 | in [cache.rs](src/cache.rs), [client.rs](src/client.rs), and [main.rs](src/main.rs) 139 | 140 | ### Fixed 141 | 142 | - The RFC file is now deleted when writing to it fails in [cache.rs](src/cache.rs) 143 | 144 | ## [0.5.2] - 2025-08-11 145 | 146 | ### Changed 147 | 148 | - Grouped maintenance commands (`--list`, `--clear-cache`, `--clear-log`) 149 | under a single `ArgGroup` for clearer help output in [main.rs](src/main.rs) 150 | 151 | ### Fixed 152 | 153 | - Relaxed positional RFC number requirement when using maintenance flags 154 | in [main.rs](src/main.rs) 155 | - Prevented combining an RFC number with maintenance actions (now rejected early) 156 | in [main.rs](src/main.rs) 157 | 158 | ## [0.5.1] - 2025-08-10 159 | 160 | ### Changed 161 | 162 | - Tightened the argument validation and reduce runtime error paths in [main.rs](src/main.rs) 163 | - The app now warns the user when the window title cannot be set in [app.rs](src/ui/app.rs) 164 | - Avoid panicking when the scroll position is out of bounds in [app.rs](src/ui/app.rs) 165 | - Use constants for UI elements in [app.rs](src/ui/app.rs) 166 | 167 | ## [0.5.0] - 2025-08-09 168 | 169 | ### Changed 170 | 171 | - Slightly modified the logging format in [logging.rs](src/ui/logging.rs) 172 | - Clamp indexes to the line length to avoid out of bounds access and use safe slicing 173 | with `str::get` for better error handling in [app.rs](src/ui/app.rs) 174 | 175 | ### Removed 176 | 177 | - Remove unnecessary `std::sync::Mutex` from `LOG_FILE_PATH` as `std::sync::LazyLock` is 178 | thread-safe in [logging.rs](src/ui/logging.rs) 179 | 180 | ## [0.4.3] - 2025-08-07 181 | 182 | ### Changed 183 | 184 | - Use `env!()` to avoid hardcoding the crate name in [logging.rs](src/ui/logging.rs) 185 | and [cache.rs](src/cache.rs) 186 | - Refactor [cache.rs](src/cache.rs) to warn the user about non-RFC documents, 187 | inform about the RFC index 188 | - Improve the RFC number extraction logic and error handling in [cache.rs](src/cache.rs) 189 | 190 | ## [0.4.2] - 2025-08-06 191 | 192 | ### Changed 193 | 194 | - Disable unused crossterm features in [Cargo.toml](Cargo.toml) to reduce compile time 195 | - Roll back crossterm version to `0.28.1` from `0.29.0` to align with ratatui's crossterm dep, 196 | avoiding compiling twice 197 | 198 | ### Removed 199 | 200 | - Remove `rustls` dependency from [Cargo.toml](Cargo.toml) to use only native-tls for ureq 201 | 202 | ## [0.4.1] - 2025-08-03 203 | 204 | ### Changed 205 | 206 | - Replace `return Err(anyhow!(...));` with `bail!(...)` cleaner code in [main.rs](src/main.rs) 207 | and [cache.rs](src/cache.rs) 208 | 209 | ## [0.4.0] - 2025-07-30 210 | 211 | ### Added 212 | 213 | - Added a minimum size for the application in [app.rs](src/ui/app.rs) 214 | - Added a message to the user when the terminal is too small in [app.rs](src/ui/app.rs) 215 | 216 | ## [0.3.1] - 2025-07-30 217 | 218 | ### Changed 219 | 220 | - Moved `tempfile` to dev dependencies. 221 | 222 | ## [0.3.0] - 2025-07-29 223 | 224 | ### Added 225 | 226 | - Refactored `RfcClient` to use `native-tls` in [client.rs](src/client.rs) 227 | - Added timeout parameter to `RfcClient` in [client.rs](src/client.rs) 228 | 229 | ## [0.2.4] - 2025-07-29 230 | 231 | ### Changed 232 | 233 | - Update lint configuration and adjust thread count for builds 234 | 235 | ## [0.2.3] - 2025-07-15 236 | 237 | ### Changed 238 | 239 | - Return `Result` instead of `Option` for better error handling in [cache.rs](src/cache.rs) 240 | 241 | ## [0.2.2] - 2025-07-13 242 | 243 | ### Changed 244 | 245 | - Use binary search instead of relying on `HashSet` in [app.rs](src/ui/app.rs) 246 | 247 | ## [0.2.1] - 2025-07-13 248 | 249 | ### Changed 250 | 251 | - update lint configuration in [Cargo.toml](Cargo.toml) 252 | 253 | ## [0.2.0] - 2025-07-11 254 | 255 | ### Added 256 | 257 | - Show the version by a `-v` flag 258 | 259 | ## [0.1.2] - 2025-07-11 260 | 261 | ### Fixed 262 | 263 | - Used `Option` to avoid confusing sentinels in [app.rs](src/ui/app.rs) 264 | 265 | ## [0.1.1] - 2025-07-11 266 | 267 | ### Added 268 | 269 | - Early return pattern for search matches to reduce code complexity in [app.rs](src/ui/app.rs) 270 | 271 | ### Changed 272 | 273 | - Refactored line highlighting logic in [app.rs](src/ui/app.rs) 274 | - Improved comments in [app.rs](src/ui/app.rs) for clarity and consistency 275 | 276 | ## [0.1.0] - Initial Release 277 | 278 | ### Added 279 | 280 | - Core displaying, fetching and caching functionalities 281 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use anyhow::{Context, Result, anyhow, bail}; 4 | use clap::{ArgAction, ArgGroup, Command, arg, crate_version}; 5 | use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; 6 | use log::{debug, error, info}; 7 | use ratatui::Terminal; 8 | use ratatui::backend::Backend as RatatuiBackend; 9 | use rfc_reader::cache::RfcCache; 10 | use rfc_reader::client::RfcClient; 11 | use rfc_reader::logging::{ 12 | clear_log_files, get_log_files_dir_path, init_logging, 13 | }; 14 | use rfc_reader::ui::guard::{init_panic_hook, init_tui}; 15 | use rfc_reader::ui::{App, AppMode, AppStateFlags, Event, EventHandler}; 16 | 17 | fn main() -> Result<()> 18 | { 19 | init_panic_hook(); 20 | init_logging()?; 21 | 22 | // Initialize cache 23 | let cache = RfcCache::new().context("Failed to initialize cache")?; 24 | 25 | // Parse command line arguments 26 | let matches = Command::new("rfc_reader") 27 | .about("A terminal-based RFC reader") 28 | .version(crate_version!()) 29 | // Inform about the cache and log directory 30 | .after_help(format!( 31 | "This program caches RFCs to improve performance.\nThe cache is \ 32 | stored in the following directory: {}\n\nThe the log files are \ 33 | stored in: {}", 34 | cache.cache_dir().display(), 35 | get_log_files_dir_path().display() 36 | )) 37 | // These args are irrelevant to `rfc`. 38 | .group(ArgGroup::new("maintenance").args([ 39 | "clear-cache", 40 | "clear-logs", 41 | "list", 42 | ])) 43 | .args([ 44 | arg!([rfc] "RFC number to open") 45 | .value_name("NUMBER") 46 | .value_parser(clap::value_parser!(u16)) 47 | .index(1) 48 | .required_unless_present("maintenance") 49 | // Disallow giving a NUMBER together with those actions 50 | .conflicts_with("maintenance"), 51 | arg!(--"clear-cache" "Clear the RFC cache") 52 | .action(ArgAction::SetTrue), 53 | arg!(--"clear-logs" "Clear the log files") 54 | .action(ArgAction::SetTrue), 55 | arg!(-o --offline "Run in offline mode (only load cached RFCs)") 56 | .action(ArgAction::SetTrue), 57 | arg!(-l --list "List all cached RFCs").action(ArgAction::SetTrue), 58 | ]) 59 | .get_matches(); 60 | 61 | // Handle maintenance actions: clear cache, clear log, list cached RFCs 62 | if matches.get_flag("clear-cache") 63 | { 64 | cache.clear()?; 65 | println!("Cache cleared successfully"); 66 | return Ok(()); 67 | } 68 | else if matches.get_flag("clear-logs") 69 | { 70 | clear_log_files()?; 71 | println!("Log files cleared successfully"); 72 | return Ok(()); 73 | } 74 | else if matches.get_flag("list") 75 | { 76 | // Print the list of all cached RFCs one per line 77 | cache.print_list(); 78 | return Ok(()); 79 | } 80 | 81 | // Setup client 82 | let client = RfcClient::default(); 83 | 84 | // Get RFC if specified 85 | let rfc_number = *matches 86 | .get_one::("rfc") 87 | .ok_or(anyhow!("RFC number is required"))?; 88 | 89 | // Get the RFC content - first check cache, then fetch from network if 90 | // needed 91 | let rfc_content = if let Ok(cached_content) = 92 | cache.get_cached_rfc(rfc_number) 93 | { 94 | info!("Using cached version of RFC {rfc_number}"); 95 | cached_content 96 | } 97 | else 98 | { 99 | let is_offline = matches.get_flag("offline"); 100 | if is_offline 101 | { 102 | error!( 103 | "RFC {rfc_number} unavailable: offline mode active and no \ 104 | cached copy found" 105 | ); 106 | 107 | bail!( 108 | "Unable to access RFC {rfc_number} - network access disabled \ 109 | in offline mode and RFC not cached locally" 110 | ); 111 | } 112 | // Fetch RFC from network since it's not in cache 113 | debug!("Fetching RFC {rfc_number} from network..."); 114 | 115 | let content = client 116 | .fetch_rfc(rfc_number) 117 | .with_context(|| format!("Failed to fetch RFC {rfc_number}"))?; 118 | 119 | // Cache the fetched content for future use. 120 | cache 121 | .cache_rfc(rfc_number, &content) 122 | .with_context(|| format!("Could not cache RFC {rfc_number}"))?; 123 | 124 | debug!("Cached RFC {rfc_number}"); 125 | content 126 | }; 127 | 128 | // Setup necessary components for the app 129 | let mut terminal = init_tui()?; 130 | 131 | let app = App::new(rfc_number, rfc_content); 132 | 133 | let event_handler = EventHandler::new(Duration::from_millis(200)); 134 | 135 | // Just propagate any error from run_app 136 | #[allow( 137 | clippy::needless_return, 138 | reason = "Explicit return for readability; the implicit return might \ 139 | look accidental." 140 | )] 141 | return run_app(&mut terminal, app, &event_handler); 142 | } 143 | 144 | /// Run the main loop 145 | /// 146 | /// # Arguments 147 | /// 148 | /// * `terminal` - The terminal to draw to 149 | /// * `app` - The app to run 150 | /// * `event_handler` - The event handler to handle events 151 | /// 152 | /// # Errors 153 | /// 154 | /// Returns an error if the terminal fails to draw to the screen. 155 | #[allow(clippy::too_many_lines, reason = "Keybindings are verbose")] 156 | fn run_app( 157 | terminal: &mut Terminal, 158 | mut app: App, 159 | event_handler: &EventHandler, 160 | ) -> Result<()> 161 | { 162 | while app 163 | .app_state 164 | .contains(AppStateFlags::SHOULD_RUN) 165 | { 166 | terminal.draw(|frame| app.render(frame))?; 167 | 168 | if let Event::Key(key) = event_handler.next()? && 169 | // This is needed in Windows 170 | key.kind == KeyEventKind::Press 171 | { 172 | match (app.mode, key.code) 173 | { 174 | // Quit with 'q' in normal mode 175 | (AppMode::Normal, KeyCode::Char('q')) => 176 | { 177 | app.app_state 178 | .remove(AppStateFlags::SHOULD_RUN); 179 | }, 180 | 181 | // Help toggle with '?' 182 | (AppMode::Normal | AppMode::Help, KeyCode::Char('?')) | 183 | (AppMode::Help, KeyCode::Esc) => 184 | { 185 | app.toggle_help(); 186 | }, 187 | // Table of contents toggle with 't' 188 | (AppMode::Normal, KeyCode::Char('t')) => 189 | { 190 | app.toggle_toc(); 191 | }, 192 | 193 | // Navigation in normal mode 194 | (AppMode::Normal, KeyCode::Char('j') | KeyCode::Down) => 195 | { 196 | app.scroll_down(1); 197 | }, 198 | (AppMode::Normal, KeyCode::Char('k') | KeyCode::Up) => 199 | { 200 | app.scroll_up(1); 201 | }, 202 | // Scroll the whole viewpoint 203 | (AppMode::Normal, KeyCode::Char('f') | KeyCode::PageDown) => 204 | { 205 | app.scroll_down(terminal.size()?.height.into()); 206 | }, 207 | (AppMode::Normal, KeyCode::Char('b') | KeyCode::PageUp) => 208 | { 209 | app.scroll_up(terminal.size()?.height.into()); 210 | }, 211 | // Whole document scroll 212 | (AppMode::Normal, KeyCode::Char('g')) => 213 | { 214 | // Use total line count instead of the byte count of the 215 | // document 216 | app.scroll_up(app.rfc_line_number); 217 | }, 218 | (AppMode::Normal, KeyCode::Char('G')) => 219 | { 220 | app.scroll_down(app.rfc_line_number); 221 | }, 222 | 223 | // Search handling 224 | (AppMode::Normal, KeyCode::Char('/')) => 225 | { 226 | app.enter_search_mode(); 227 | }, 228 | (AppMode::Search, KeyCode::Enter) => 229 | { 230 | app.perform_search(); 231 | app.exit_search_mode(); 232 | }, 233 | (AppMode::Search, KeyCode::Esc) => 234 | { 235 | app.exit_search_mode(); 236 | }, 237 | (AppMode::Search, KeyCode::Backspace) => 238 | { 239 | app.remove_search_char(); 240 | }, 241 | (AppMode::Search, KeyCode::Delete) => 242 | { 243 | app.delete_search_char(); 244 | }, 245 | // Cursor navigation 246 | (AppMode::Search, KeyCode::Left) => 247 | { 248 | app.move_search_cursor_left(); 249 | }, 250 | (AppMode::Search, KeyCode::Right) => 251 | { 252 | app.move_search_cursor_right(); 253 | }, 254 | (AppMode::Search, KeyCode::Home) => 255 | { 256 | app.move_search_cursor_home(); 257 | }, 258 | (AppMode::Search, KeyCode::End) => 259 | { 260 | app.move_search_cursor_end(); 261 | }, 262 | // Ctrl + c toggles case sensitive mode 263 | (AppMode::Search, KeyCode::Char('c')) 264 | if key.modifiers == KeyModifiers::CONTROL => 265 | { 266 | app.toggle_case_sensitivity(); 267 | }, 268 | // Ctrl + r toggles regex mode 269 | (AppMode::Search, KeyCode::Char('r')) 270 | if key.modifiers == KeyModifiers::CONTROL => 271 | { 272 | app.toggle_regex_mode(); 273 | }, 274 | (AppMode::Search, KeyCode::Char(ch)) => 275 | { 276 | app.add_search_char(ch); 277 | }, 278 | 279 | // Search result navigation 280 | (AppMode::Normal, KeyCode::Char('n')) => 281 | { 282 | app.next_search_result(); 283 | }, 284 | (AppMode::Normal, KeyCode::Char('N')) => 285 | { 286 | app.prev_search_result(); 287 | }, 288 | (AppMode::Normal, KeyCode::Esc) => 289 | { 290 | app.reset_search_highlights(); 291 | }, 292 | 293 | // ToC navigation 294 | (AppMode::Normal, KeyCode::Char('w')) 295 | if app 296 | .app_state 297 | .contains(AppStateFlags::SHOULD_SHOW_TOC) => 298 | { 299 | app.rfc_toc_panel.previous(); 300 | }, 301 | (AppMode::Normal, KeyCode::Char('s')) 302 | if app 303 | .app_state 304 | .contains(AppStateFlags::SHOULD_SHOW_TOC) => 305 | { 306 | app.rfc_toc_panel.next(); 307 | }, 308 | (AppMode::Normal, KeyCode::Enter) 309 | if app 310 | .app_state 311 | .contains(AppStateFlags::SHOULD_SHOW_TOC) => 312 | { 313 | app.jump_to_toc_entry(); 314 | }, 315 | 316 | _ => 317 | {}, // Ignore other key combinations 318 | } 319 | } 320 | } 321 | 322 | Ok(()) 323 | } 324 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist 2 | # 3 | # Copyright 2022-2024, axodotdev 4 | # SPDX-License-Identifier: MIT or Apache-2.0 5 | # 6 | # CI that: 7 | # 8 | # * checks for a Git Tag that looks like a release 9 | # * builds artifacts with dist (archives, installers, hashes) 10 | # * uploads those artifacts to temporary workflow zip 11 | # * on success, uploads the artifacts to a GitHub Release 12 | # 13 | # Note that the GitHub Release will be created with a generated 14 | # title/body based on your changelogs. 15 | 16 | name: Release 17 | permissions: 18 | "contents": "write" 19 | 20 | # This task will run whenever you push a git tag that looks like a version 21 | # like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. 22 | # Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where 23 | # PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION 24 | # must be a Cargo-style SemVer Version (must have at least major.minor.patch). 25 | # 26 | # If PACKAGE_NAME is specified, then the announcement will be for that 27 | # package (erroring out if it doesn't have the given version or isn't dist-able). 28 | # 29 | # If PACKAGE_NAME isn't specified, then the announcement will be for all 30 | # (dist-able) packages in the workspace with that version (this mode is 31 | # intended for workspaces with only one dist-able package, or with all dist-able 32 | # packages versioned/released in lockstep). 33 | # 34 | # If you push multiple tags at once, separate instances of this workflow will 35 | # spin up, creating an independent announcement for each one. However, GitHub 36 | # will hard limit this to 3 tags per commit, as it will assume more tags is a 37 | # mistake. 38 | # 39 | # If there's a prerelease-style suffix to the version, then the release(s) 40 | # will be marked as a prerelease. 41 | on: 42 | pull_request: 43 | push: 44 | tags: 45 | - '**[0-9]+.[0-9]+.[0-9]+*' 46 | 47 | jobs: 48 | # Run 'dist plan' (or host) to determine what tasks we need to do 49 | plan: 50 | runs-on: "ubuntu-22.04" 51 | outputs: 52 | val: ${{ steps.plan.outputs.manifest }} 53 | tag: ${{ !github.event.pull_request && github.ref_name || '' }} 54 | tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} 55 | publishing: ${{ !github.event.pull_request }} 56 | env: 57 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | steps: 59 | - uses: actions/checkout@v4 60 | with: 61 | persist-credentials: false 62 | submodules: recursive 63 | - name: Install dist 64 | # we specify bash to get pipefail; it guards against the `curl` command 65 | # failing. otherwise `sh` won't catch that `curl` returned non-0 66 | shell: bash 67 | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.0/cargo-dist-installer.sh | sh" 68 | - name: Cache dist 69 | uses: actions/upload-artifact@v4 70 | with: 71 | name: cargo-dist-cache 72 | path: ~/.cargo/bin/dist 73 | # sure would be cool if github gave us proper conditionals... 74 | # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible 75 | # functionality based on whether this is a pull_request, and whether it's from a fork. 76 | # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* 77 | # but also really annoying to build CI around when it needs secrets to work right.) 78 | - id: plan 79 | run: | 80 | dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json 81 | echo "dist ran successfully" 82 | cat plan-dist-manifest.json 83 | echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" 84 | - name: "Upload dist-manifest.json" 85 | uses: actions/upload-artifact@v4 86 | with: 87 | name: artifacts-plan-dist-manifest 88 | path: plan-dist-manifest.json 89 | 90 | # Build and packages all the platform-specific things 91 | build-local-artifacts: 92 | name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) 93 | # Let the initial task tell us to not run (currently very blunt) 94 | needs: 95 | - plan 96 | if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} 97 | strategy: 98 | fail-fast: false 99 | # Target platforms/runners are computed by dist in create-release. 100 | # Each member of the matrix has the following arguments: 101 | # 102 | # - runner: the github runner 103 | # - dist-args: cli flags to pass to dist 104 | # - install-dist: expression to run to install dist on the runner 105 | # 106 | # Typically there will be: 107 | # - 1 "global" task that builds universal installers 108 | # - N "local" tasks that build each platform's binaries and platform-specific installers 109 | matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} 110 | runs-on: ${{ matrix.runner }} 111 | container: ${{ matrix.container && matrix.container.image || null }} 112 | env: 113 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 114 | BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json 115 | steps: 116 | - name: enable windows longpaths 117 | run: | 118 | git config --global core.longpaths true 119 | - uses: actions/checkout@v4 120 | with: 121 | persist-credentials: false 122 | submodules: recursive 123 | - name: Install Rust non-interactively if not already installed 124 | if: ${{ matrix.container }} 125 | run: | 126 | if ! command -v cargo > /dev/null 2>&1; then 127 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 128 | echo "$HOME/.cargo/bin" >> $GITHUB_PATH 129 | fi 130 | - name: Install dist 131 | run: ${{ matrix.install_dist.run }} 132 | # Get the dist-manifest 133 | - name: Fetch local artifacts 134 | uses: actions/download-artifact@v4 135 | with: 136 | pattern: artifacts-* 137 | path: target/distrib/ 138 | merge-multiple: true 139 | - name: Install dependencies 140 | run: | 141 | ${{ matrix.packages_install }} 142 | - name: Build artifacts 143 | run: | 144 | # Actually do builds and make zips and whatnot 145 | dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json 146 | echo "dist ran successfully" 147 | - id: cargo-dist 148 | name: Post-build 149 | # We force bash here just because github makes it really hard to get values up 150 | # to "real" actions without writing to env-vars, and writing to env-vars has 151 | # inconsistent syntax between shell and powershell. 152 | shell: bash 153 | run: | 154 | # Parse out what we just built and upload it to scratch storage 155 | echo "paths<> "$GITHUB_OUTPUT" 156 | dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" 157 | echo "EOF" >> "$GITHUB_OUTPUT" 158 | 159 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 160 | - name: "Upload artifacts" 161 | uses: actions/upload-artifact@v4 162 | with: 163 | name: artifacts-build-local-${{ join(matrix.targets, '_') }} 164 | path: | 165 | ${{ steps.cargo-dist.outputs.paths }} 166 | ${{ env.BUILD_MANIFEST_NAME }} 167 | 168 | # Build and package all the platform-agnostic(ish) things 169 | build-global-artifacts: 170 | needs: 171 | - plan 172 | - build-local-artifacts 173 | runs-on: "ubuntu-22.04" 174 | env: 175 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 176 | BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json 177 | steps: 178 | - uses: actions/checkout@v4 179 | with: 180 | persist-credentials: false 181 | submodules: recursive 182 | - name: Install cached dist 183 | uses: actions/download-artifact@v4 184 | with: 185 | name: cargo-dist-cache 186 | path: ~/.cargo/bin/ 187 | - run: chmod +x ~/.cargo/bin/dist 188 | # Get all the local artifacts for the global tasks to use (for e.g. checksums) 189 | - name: Fetch local artifacts 190 | uses: actions/download-artifact@v4 191 | with: 192 | pattern: artifacts-* 193 | path: target/distrib/ 194 | merge-multiple: true 195 | - id: cargo-dist 196 | shell: bash 197 | run: | 198 | dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json 199 | echo "dist ran successfully" 200 | 201 | # Parse out what we just built and upload it to scratch storage 202 | echo "paths<> "$GITHUB_OUTPUT" 203 | jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" 204 | echo "EOF" >> "$GITHUB_OUTPUT" 205 | 206 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 207 | - name: "Upload artifacts" 208 | uses: actions/upload-artifact@v4 209 | with: 210 | name: artifacts-build-global 211 | path: | 212 | ${{ steps.cargo-dist.outputs.paths }} 213 | ${{ env.BUILD_MANIFEST_NAME }} 214 | # Determines if we should publish/announce 215 | host: 216 | needs: 217 | - plan 218 | - build-local-artifacts 219 | - build-global-artifacts 220 | # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) 221 | if: ${{ always() && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} 222 | env: 223 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 224 | runs-on: "ubuntu-22.04" 225 | outputs: 226 | val: ${{ steps.host.outputs.manifest }} 227 | steps: 228 | - uses: actions/checkout@v4 229 | with: 230 | persist-credentials: false 231 | submodules: recursive 232 | - name: Install cached dist 233 | uses: actions/download-artifact@v4 234 | with: 235 | name: cargo-dist-cache 236 | path: ~/.cargo/bin/ 237 | - run: chmod +x ~/.cargo/bin/dist 238 | # Fetch artifacts from scratch-storage 239 | - name: Fetch artifacts 240 | uses: actions/download-artifact@v4 241 | with: 242 | pattern: artifacts-* 243 | path: target/distrib/ 244 | merge-multiple: true 245 | - id: host 246 | shell: bash 247 | run: | 248 | dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json 249 | echo "artifacts uploaded and released successfully" 250 | cat dist-manifest.json 251 | echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" 252 | - name: "Upload dist-manifest.json" 253 | uses: actions/upload-artifact@v4 254 | with: 255 | # Overwrite the previous copy 256 | name: artifacts-dist-manifest 257 | path: dist-manifest.json 258 | # Create a GitHub Release while uploading all files to it 259 | - name: "Download GitHub Artifacts" 260 | uses: actions/download-artifact@v4 261 | with: 262 | pattern: artifacts-* 263 | path: artifacts 264 | merge-multiple: true 265 | - name: Cleanup 266 | run: | 267 | # Remove the granular manifests 268 | rm -f artifacts/*-dist-manifest.json 269 | - name: Create GitHub Release 270 | env: 271 | PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" 272 | ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" 273 | ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" 274 | RELEASE_COMMIT: "${{ github.sha }}" 275 | run: | 276 | # Write and read notes from a file to avoid quoting breaking things 277 | echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt 278 | 279 | gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* 280 | 281 | announce: 282 | needs: 283 | - plan 284 | - host 285 | # use "always() && ..." to allow us to wait for all publish jobs while 286 | # still allowing individual publish jobs to skip themselves (for prereleases). 287 | # "host" however must run to completion, no skipping allowed! 288 | if: ${{ always() && needs.host.result == 'success' }} 289 | runs-on: "ubuntu-22.04" 290 | env: 291 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 292 | steps: 293 | - uses: actions/checkout@v4 294 | with: 295 | persist-credentials: false 296 | submodules: recursive 297 | -------------------------------------------------------------------------------- /src/cache.rs: -------------------------------------------------------------------------------- 1 | //! Manages local caching of RFC documents. 2 | //! 3 | //! Stores document content on disk to minimize redundant network requests. 4 | use std::fs::{self, File}; 5 | use std::io::Write; 6 | use std::path::Path; 7 | 8 | use anyhow::{Context, Result, bail}; 9 | use directories::ProjectDirs; 10 | 11 | /// Cache for storing RFC documents locally. 12 | /// 13 | /// Provides functionality to read and write RFCs to disk, 14 | /// reducing the need for repeated network requests. 15 | pub struct RfcCache 16 | { 17 | /// Directory where cache files are stored 18 | cache_dir: Box, 19 | } 20 | 21 | impl RfcCache 22 | { 23 | /// Creates a new `RfcCache` instance. 24 | /// 25 | /// Creates the cache directory if it doesn't already exist. 26 | /// 27 | /// # Returns 28 | /// 29 | /// A Result containing the new `RfcCache` or an error if the cache 30 | /// directory could not be determined or created. 31 | /// 32 | /// # Errors 33 | /// 34 | /// Returns an error if the cache directory cannot be determined or created. 35 | pub fn new() -> Result 36 | { 37 | let project_dirs = ProjectDirs::from("", "", env!("CARGO_PKG_NAME")) 38 | .context("Failed to determine project directories")?; 39 | 40 | let cache_dir = project_dirs.cache_dir(); 41 | // Create if cache_dir doesn't exist. 42 | fs::create_dir_all(cache_dir) 43 | .context("Failed to create cache directory")?; 44 | 45 | Ok(Self { 46 | cache_dir: cache_dir.into(), 47 | }) 48 | } 49 | 50 | /// Retrieves an RFC from the cache. 51 | /// 52 | /// # Arguments 53 | /// 54 | /// * `rfc_number` - The RFC number to retrieve 55 | /// 56 | /// # Returns 57 | /// 58 | /// A Result containing the content of the RFC if it exists in the cache, 59 | /// or an error if the RFC is not cached or cannot be read. 60 | /// 61 | /// # Errors 62 | /// 63 | /// Returns an error if the cached RFC does not exist or cannot be read. 64 | pub fn get_cached_rfc(&self, rfc_number: u16) -> Result> 65 | { 66 | let rfc_path = self.format_cache_path(rfc_number); 67 | 68 | if !rfc_path.exists() 69 | { 70 | bail!( 71 | "Cached RFC {rfc_number} does not exist at {}", 72 | rfc_path.display() 73 | ); 74 | } 75 | 76 | let content = fs::read_to_string(&rfc_path).with_context(|| { 77 | format!( 78 | "Failed to read cached RFC {rfc_number} from {}", 79 | rfc_path.display() 80 | ) 81 | })?; 82 | 83 | Ok(content.into_boxed_str()) 84 | } 85 | 86 | /// Stores an RFC in the cache. 87 | /// 88 | /// # Arguments 89 | /// 90 | /// * `rfc_number` - The RFC number to cache 91 | /// * `content` - The content of the RFC to store 92 | /// 93 | /// # Returns 94 | /// 95 | /// A Result indicating success or an error if writing to the cache failed. 96 | /// 97 | /// # Errors 98 | /// 99 | /// Returns an error if the cache file cannot be created or written to. 100 | pub fn cache_rfc(&self, rfc_number: u16, content: &str) -> Result<()> 101 | { 102 | let rfc_path = self.format_cache_path(rfc_number); 103 | 104 | let mut file = File::create(&rfc_path).with_context(|| { 105 | format!("Failed to create cache file for RFC {rfc_number}") 106 | })?; 107 | 108 | // Write the contents. 109 | if let Err(write_err) = file.write_all(content.as_bytes()) 110 | { 111 | // We already messed up but the empty file remains. 112 | // Attempt cleanup, but don't let the cleanup errors override the 113 | // original error 114 | let _ = fs::remove_file(&rfc_path); 115 | 116 | bail!(write_err); 117 | } 118 | 119 | Ok(()) 120 | } 121 | 122 | /// Retrieves the RFC index from the cache. 123 | /// 124 | /// # Returns 125 | /// 126 | /// A Result containing the content of the RFC index if it exists in the 127 | /// cache, or an error if the index is not cached or cannot be read. 128 | /// 129 | /// # Errors 130 | /// 131 | /// Returns an error if the cached index does not exist or cannot be read. 132 | pub fn get_cached_index(&self) -> Result> 133 | { 134 | let path = self.get_index_cache_path(); 135 | 136 | if !path.exists() 137 | { 138 | bail!("Cached RFC index does not exist at {}", path.display()); 139 | } 140 | 141 | let content = fs::read_to_string(&path).with_context(|| { 142 | format!("Failed to read cached RFC index from {}", path.display()) 143 | })?; 144 | 145 | Ok(content.into_boxed_str()) 146 | } 147 | 148 | /// Stores the RFC index in the cache. 149 | /// 150 | /// # Arguments 151 | /// 152 | /// * `content` - The content of the RFC index to store 153 | /// 154 | /// # Returns 155 | /// 156 | /// A Result indicating success or an error if writing to the cache failed. 157 | /// 158 | /// # Errors 159 | /// 160 | /// Returns an error if the cache file cannot be created or written to. 161 | pub fn cache_index(&self, content: &str) -> Result<()> 162 | { 163 | let path = self.get_index_cache_path(); 164 | 165 | let mut file = File::create(&path) 166 | .context("Failed to create cache file for RFC index")?; 167 | 168 | file.write_all(content.as_bytes()) 169 | .context("Failed to write RFC index to cache")?; 170 | 171 | Ok(()) 172 | } 173 | 174 | /// Format the file path for a specific RFC in the cache. 175 | /// 176 | /// # Arguments 177 | /// 178 | /// * `rfc_number` - The RFC number 179 | /// 180 | /// # Returns 181 | /// 182 | /// The path where the RFC should be cached 183 | fn format_cache_path(&self, rfc_number: u16) -> Box 184 | { 185 | self.cache_dir 186 | .join(format!("rfc{rfc_number}.txt")) 187 | .into_boxed_path() 188 | } 189 | 190 | /// Gets the file path for the RFC index in the cache. 191 | /// 192 | /// # Returns 193 | /// 194 | /// The path where the RFC index should be cached 195 | fn get_index_cache_path(&self) -> Box 196 | { 197 | self.cache_dir 198 | .join("rfc-index.txt") 199 | .into_boxed_path() 200 | } 201 | 202 | /// Clears all cached RFCs and the index. 203 | /// 204 | /// # Returns 205 | /// 206 | /// A Result indicating success or an error if clearing the cache failed. 207 | /// 208 | /// # Errors 209 | /// 210 | /// Returns an error if removing files from the cache directory fails. 211 | pub fn clear(&self) -> Result<()> 212 | { 213 | // Read the directory entries 214 | let entries = fs::read_dir(&self.cache_dir) 215 | .context("Failed to read cache directory")?; 216 | 217 | // Remove each file or directory in the cache directory 218 | for entry in entries.filter_map(Result::ok) 219 | { 220 | let path = entry.path(); 221 | 222 | if path.is_file() 223 | { 224 | fs::remove_file(&path).with_context(|| { 225 | format!("Failed to remove cache file: {}", path.display()) 226 | })?; 227 | } 228 | else if path.is_dir() 229 | { 230 | fs::remove_dir_all(&path).with_context(|| { 231 | format!( 232 | "Failed to remove cache directory: {}", 233 | path.display() 234 | ) 235 | })?; 236 | } 237 | } 238 | 239 | // Remove the directory if it is empty. 240 | let is_empty = self 241 | .cache_dir 242 | .read_dir() 243 | .context("Failed to check if cache directory is empty")? 244 | .next() 245 | .is_none(); 246 | 247 | if is_empty 248 | { 249 | fs::remove_dir(&self.cache_dir) 250 | .context("Failed to remove empty cache directory")?; 251 | } 252 | 253 | Ok(()) 254 | } 255 | 256 | /// Get the cache directory. 257 | /// 258 | /// # Returns 259 | /// 260 | /// The cache directory. 261 | #[must_use] 262 | pub const fn cache_dir(&self) -> &Path 263 | { 264 | &self.cache_dir 265 | } 266 | 267 | /// List the cached RFCs. 268 | /// 269 | /// # Panics 270 | /// 271 | /// Panics if the cache directory cannot be read. 272 | pub fn print_list(&self) 273 | { 274 | // Read the directory entries. 275 | let entries: Vec<_> = fs::read_dir(&self.cache_dir) 276 | .expect("Failed to read cache directory") 277 | .filter_map(Result::ok) 278 | .collect(); 279 | 280 | if entries.is_empty() 281 | { 282 | println!("No cached RFCs found."); 283 | return; 284 | } 285 | 286 | println!("List of cached RFCs:"); 287 | 288 | for entry in entries 289 | { 290 | let path = entry.path(); 291 | if path.is_file() 292 | { 293 | let file_name = path 294 | .file_name() 295 | .expect("Failed to get file name") 296 | .to_string_lossy(); 297 | 298 | // Skip the index file 299 | if file_name == "rfc-index.txt" 300 | { 301 | println!("- RFC Index"); 302 | } 303 | // Extract the RFC number from the file name. 304 | else if let Some(rfc_num) = file_name 305 | .strip_prefix("rfc") 306 | .and_then(|name| name.strip_suffix(".txt")) 307 | { 308 | println!("- RFC {rfc_num}"); 309 | } 310 | else 311 | { 312 | // Warn the user for stray files 313 | println!("{} (not a valid RFC document)", file_name); 314 | } 315 | } 316 | } 317 | } 318 | } 319 | 320 | #[cfg(test)] 321 | mod tests 322 | { 323 | use std::fs::File; 324 | use std::io::Write; 325 | 326 | use tempfile::TempDir; 327 | 328 | use super::*; 329 | 330 | #[test] 331 | fn test_clear_with_files() -> Result<()> 332 | { 333 | // Create a temporary directory for testing 334 | let temp_dir = TempDir::new()?; 335 | let cache_dir = temp_dir.path(); 336 | 337 | // Bypass the ctor for the temp dir. 338 | let cache = RfcCache { 339 | cache_dir: cache_dir.into(), 340 | }; 341 | 342 | // Create test files in the temp dir 343 | let file_paths = vec!["file1.txt", "file2.txt", "file3.txt"]; 344 | for file_name in &file_paths 345 | { 346 | let file_path = cache_dir.join(file_name); 347 | let mut file = File::create(&file_path)?; 348 | writeln!(file, "test content")?; 349 | } 350 | 351 | // Verify files exist before clearing 352 | for file_name in &file_paths 353 | { 354 | assert!(cache_dir.join(file_name).exists()); 355 | } 356 | 357 | cache.clear()?; 358 | 359 | // Verify all files have been deleted 360 | for file_name in &file_paths 361 | { 362 | assert!(!cache_dir.join(file_name).exists()); 363 | } 364 | 365 | // Verify the directory has been removed 366 | assert!(!cache_dir.exists()); 367 | 368 | Ok(()) 369 | } 370 | 371 | #[test] 372 | fn test_clear_with_no_files() -> Result<()> 373 | { 374 | // Create a temporary directory for testing 375 | let temp_dir = TempDir::new()?; 376 | let cache_dir = temp_dir.path(); 377 | 378 | let cache = RfcCache { 379 | cache_dir: cache_dir.into(), 380 | }; 381 | 382 | // Call the clear function on an empty directory 383 | cache.clear()?; 384 | 385 | // Verify the directory has been removed 386 | assert!(!cache_dir.exists()); 387 | 388 | Ok(()) 389 | } 390 | 391 | #[test] 392 | fn test_clear_with_mixed_content() -> Result<()> 393 | { 394 | // Create a temporary directory for testing 395 | let temp_dir = TempDir::new()?; 396 | let cache_dir = temp_dir.path(); 397 | 398 | // Create an instance with the temp directory 399 | let cache = RfcCache { 400 | cache_dir: cache_dir.into(), 401 | }; 402 | 403 | // Create a file 404 | let file_path = cache_dir.join("file.txt"); 405 | let mut file = File::create(&file_path)?; 406 | writeln!(file, "test content")?; 407 | 408 | // Create a subdirectory 409 | let subdir_path = cache_dir.join("subdir"); 410 | std::fs::create_dir(&subdir_path)?; 411 | 412 | cache.clear()?; 413 | 414 | // Verify the file is gone 415 | assert!(!file_path.exists()); 416 | 417 | // Verify the cache directory is gone 418 | assert!(!cache_dir.exists()); 419 | 420 | // The subdirectory should be removed 421 | assert!(!subdir_path.exists()); 422 | 423 | Ok(()) 424 | } 425 | 426 | #[test] 427 | fn test_rfc_round_trip() -> Result<()> 428 | { 429 | let temp_dir = TempDir::new()?; 430 | let cache = RfcCache { 431 | cache_dir: temp_dir.path().into(), 432 | }; 433 | 434 | let rfc_number = 1234; 435 | let content = "RFC Content Test"; 436 | 437 | // Cache the bogus RFC 438 | cache.cache_rfc(rfc_number, content)?; 439 | 440 | // Verify file exists on disk 441 | let expected_path = temp_dir 442 | .path() 443 | .join(format!("rfc{rfc_number}.txt")); 444 | assert!(expected_path.exists()); 445 | 446 | // Retrieve the bogus RFC 447 | let cached_content = cache.get_cached_rfc(rfc_number)?; 448 | assert_eq!(cached_content.as_ref(), content); 449 | 450 | Ok(()) 451 | } 452 | } 453 | -------------------------------------------------------------------------------- /src/ui/toc_panel.rs: -------------------------------------------------------------------------------- 1 | //! Manages the RFC Table of Contents panel. 2 | //! 3 | //! Displays, navigates, and tracks selection for RFC document entries. 4 | use ratatui::Frame; 5 | use ratatui::layout::{Alignment, Rect}; 6 | use ratatui::style::{Color, Modifier, Style}; 7 | use ratatui::text::Line; 8 | use ratatui::widgets::{Block, Borders, List, ListItem, ListState}; 9 | use regex::Regex; 10 | use textwrap::wrap; 11 | 12 | use super::app::LineNumber; 13 | 14 | // Style for each individual ToC entry 15 | const TOC_HIGHLIGHT_STYLE: Style = Style::new() 16 | .fg(Color::LightYellow) 17 | .add_modifier(Modifier::BOLD); 18 | 19 | // Style for the ToC border 20 | const TOC_BORDER_STYLE: Style = Style::new().fg(Color::Gray); 21 | 22 | // Symbol used to highlight the currently selected ToC entry 23 | const TOC_HIGHLIGHT_SYMBOL: &str = "> "; 24 | 25 | /// Represents a table of contents entry. 26 | /// 27 | /// Contains a title and its document line number. 28 | #[derive(Debug, Clone, Default)] 29 | pub struct TocEntry 30 | { 31 | /// The title text of the section 32 | pub title: Box, 33 | /// The line number where this section appears in the document 34 | pub line_number: LineNumber, 35 | } 36 | 37 | /// Panel that displays and manages a table of contents. 38 | /// 39 | /// Provides navigation capabilities and tracks the currently selected entry. 40 | #[derive(Default)] 41 | pub struct TocPanel 42 | { 43 | /// Collection of table of contents entries 44 | entries: Vec, 45 | /// Current selection state 46 | state: ListState, 47 | } 48 | 49 | impl TocPanel 50 | { 51 | /// Creates a new `TocPanel` from document content. 52 | /// 53 | /// Parses the content to extract a table of contents and initializes 54 | /// the selection state to the first entry if available. 55 | /// 56 | /// # Arguments 57 | /// 58 | /// * `content` - The document content to parse 59 | /// 60 | /// # Returns 61 | /// 62 | /// A new `TocPanel` instance 63 | pub fn new(content: &str) -> Self 64 | { 65 | let entries = parsing::parse_toc(content); 66 | let mut state = ListState::default(); 67 | 68 | if !entries.is_empty() 69 | { 70 | state.select(Some(0)); 71 | } 72 | 73 | Self { entries, state } 74 | } 75 | 76 | /// Returns a slice of `ToC` entries, sorted by their first appearance. 77 | /// 78 | /// # Returns 79 | /// 80 | /// A slice of all `ToC` entries. 81 | pub fn entries(&self) -> &[TocEntry] 82 | { 83 | &self.entries 84 | } 85 | 86 | /// Renders the table of contents panel to the specified area. 87 | /// 88 | /// # Arguments 89 | /// 90 | /// * `frame` - The frame to render to 91 | /// * `area` - The area within the frame to render the panel 92 | pub fn render(&mut self, frame: &mut Frame, area: Rect) 93 | { 94 | // Long titles need to be wrapped to fit within the panel width. 95 | // 2 for the border 96 | #[expect( 97 | clippy::arithmetic_side_effects, 98 | reason = "usize not expected to overflow" 99 | )] 100 | let wrap_width = (area.width as usize) 101 | .saturating_sub(TOC_HIGHLIGHT_SYMBOL.len() + 2); 102 | 103 | let items: Vec = self 104 | .entries 105 | .iter() 106 | .map(|entry| { 107 | let wrapped_title = wrap(&entry.title, wrap_width) 108 | .into_iter() 109 | .map(Line::raw) 110 | .collect::>(); 111 | 112 | ListItem::new(wrapped_title) 113 | }) 114 | .collect(); 115 | 116 | let list = List::new(items) 117 | .block( 118 | Block::default() 119 | .borders(Borders::RIGHT) 120 | .border_style(TOC_BORDER_STYLE) 121 | .title("Contents") 122 | .title_alignment(Alignment::Left) 123 | .title_style( 124 | Style::new() 125 | .fg(Color::White) 126 | .add_modifier(Modifier::BOLD), 127 | ), 128 | ) 129 | .highlight_style(TOC_HIGHLIGHT_STYLE) 130 | .highlight_symbol(TOC_HIGHLIGHT_SYMBOL); 131 | 132 | frame.render_stateful_widget(list, area, &mut self.state); 133 | } 134 | 135 | /// Moves the selection to the next entry. 136 | pub fn next(&mut self) 137 | { 138 | if let Some(i) = self.state.selected() 139 | { 140 | self.state.select(Some(i.saturating_add(1))); 141 | } 142 | } 143 | 144 | /// Moves the selection to the previous entry. 145 | pub fn previous(&mut self) 146 | { 147 | if let Some(i) = self.state.selected() 148 | { 149 | self.state.select(Some(i.saturating_sub(1))); 150 | } 151 | } 152 | 153 | /// Returns the line number of the currently selected entry. 154 | /// 155 | /// # Returns 156 | /// 157 | /// The line number of the selected entry, or `None` if no entry is selected 158 | /// or the entries list is empty. 159 | pub fn selected_line(&self) -> Option 160 | { 161 | if self.entries.is_empty() 162 | { 163 | return None; 164 | } 165 | 166 | self.state 167 | .selected() 168 | .map(|i| self.entries[i].line_number) 169 | } 170 | } 171 | 172 | pub mod parsing 173 | { 174 | use std::str::Lines; 175 | use std::sync::LazyLock; 176 | 177 | use super::{LineNumber, Regex, TocEntry}; 178 | 179 | // Static regex patterns for better performance 180 | // 181 | // Note: Don't trim the leading whitespace or eat the other chars 182 | // before beginning of the line so that we can distinguish the actual 183 | // ToC entries from the section headings by preserving the indentation. 184 | static TOC_HEADER_REGEX: LazyLock = LazyLock::new(|| { 185 | let toc_entries = [ 186 | r"(?:Table of Contents|Contents)", // Standard header 187 | r"(?:TABLE OF CONTENTS)", // All caps variant 188 | r"(?:\d+\.?\s+Table of Contents)", // Numbered ToC section 189 | ]; 190 | let pattern = format!("^({})$", toc_entries.join("|")); 191 | Regex::new(&pattern).expect("Invalid TOC header regex") 192 | }); 193 | 194 | // Acknowledgements, authors' addresses, etc. aren't included. 195 | static TOC_ENTRY_PATTERNS: LazyLock> = LazyLock::new(|| { 196 | // Account for the leading whitespace in the entries 197 | vec![ 198 | // Standard format with dots: "1. Introduction..................5" 199 | Regex::new(r"^\s*(\d+(?:\.\d+)*\.?)\s+(.*?)(?:\.{2,}\s*\d+)?$") 200 | .expect("Invalid TOC entry regex"), 201 | // Appendix format: " Appendix A. Example" 202 | Regex::new(r"^\s*(Appendix\s+[A-Z]\.?)\s+(.*?)(?:\.{2,}\s*\d+)?$") 203 | .expect("Invalid appendix regex"), 204 | ] 205 | }); 206 | 207 | static SECTION_HEADING_REGEX: LazyLock = LazyLock::new(|| { 208 | Regex::new(r"^(\d+(?:\.\d+)*\.)\s+\S") 209 | .expect("Invalid section heading regex") 210 | }); 211 | 212 | /// Parses the document by existing `ToC`. 213 | /// 214 | /// # Arguments 215 | /// 216 | /// * `content` - The document content to parse 217 | /// 218 | /// # Returns 219 | /// 220 | /// A vector of `TocEntry` instances representing the document's structure 221 | /// or `None` if no `ToC` is found. 222 | fn parse_toc_existing(content: &str) -> Option> 223 | { 224 | let lines = content.lines(); 225 | 226 | // Find ToC start 227 | let start_index = find_toc_start(lines.clone())?; 228 | 229 | // Process ToC entries 230 | let entries = extract_toc_entries(&lines, start_index); 231 | 232 | if entries.is_empty() 233 | { 234 | None 235 | } 236 | else 237 | { 238 | Some(entries) 239 | } 240 | } 241 | 242 | /// Find the start of `ToC` section. 243 | /// 244 | /// # Arguments 245 | /// 246 | /// * `lines` - The lines of the document 247 | /// * `toc_regex` - The regex to find the `ToC` header 248 | /// 249 | /// # Returns 250 | /// 251 | /// The index of the start of the `ToC` section, or `None` if no `ToC` is 252 | /// found. 253 | fn find_toc_start(lines: Lines<'_>) -> Option 254 | { 255 | lines.enumerate().find_map(|(index, line)| { 256 | if TOC_HEADER_REGEX.is_match(line.trim()) 257 | { 258 | #[expect( 259 | clippy::arithmetic_side_effects, 260 | reason = "LineNumber not expected to overflow" 261 | )] 262 | Some(index + 1) // Skip the `ToC` header line 263 | } 264 | else 265 | { 266 | None 267 | } 268 | }) 269 | } 270 | 271 | /// Extract `ToC` entries from content. 272 | /// 273 | /// # Arguments 274 | /// 275 | /// * `lines` - The lines of the document 276 | /// * `start_index` - The index of the start of the `ToC` section 277 | /// 278 | /// # Returns 279 | /// 280 | /// A vector of `TocEntry` instances representing the document's structure 281 | fn extract_toc_entries( 282 | lines: &Lines<'_>, 283 | start_index: LineNumber, 284 | ) -> Vec 285 | { 286 | let mut entries = Vec::new(); 287 | let mut consecutive_empty_lines = 0; 288 | let mut has_found_entries = false; 289 | let mut lines_without_entries = 0; 290 | 291 | for (index, trimmed_line) in lines 292 | .clone() 293 | .enumerate() 294 | .skip(start_index) 295 | .map(|(i, line)| (i, line.trim_end())) 296 | { 297 | // Check stopping conditions 298 | if should_stop_parsing( 299 | trimmed_line, 300 | has_found_entries, 301 | &mut consecutive_empty_lines, 302 | &mut lines_without_entries, 303 | ) 304 | { 305 | break; 306 | } 307 | 308 | // Try to match and extract entries 309 | if let Some(entry) = 310 | try_extract_entry(trimmed_line, lines.clone(), index) 311 | { 312 | has_found_entries = true; 313 | entries.push(entry); 314 | } 315 | } 316 | 317 | entries 318 | } 319 | 320 | /// Check if we should stop parsing the `ToC` 321 | /// 322 | /// # Arguments 323 | /// 324 | /// * `trimmed_line` - The trimmed line to check 325 | /// * `has_found_entries` - Whether we have found any entries 326 | /// * `consecutive_empty_lines` - The number of consecutive empty lines 327 | /// * `lines_without_entries` - The number of lines without entries 328 | /// 329 | /// # Returns 330 | /// 331 | /// A boolean indicating whether we should stop parsing the `ToC` 332 | fn should_stop_parsing( 333 | trimmed_line: &str, 334 | has_found_entries: bool, 335 | consecutive_empty_lines: &mut u8, 336 | lines_without_entries: &mut u8, 337 | ) -> bool 338 | { 339 | // 1. Check for section headings outside ToC 340 | let does_look_like_section = 341 | SECTION_HEADING_REGEX.is_match(trimmed_line); 342 | let is_matching_toc_pattern = TOC_ENTRY_PATTERNS 343 | .iter() 344 | .any(|re| re.is_match(trimmed_line)); 345 | 346 | if does_look_like_section && 347 | !is_matching_toc_pattern && 348 | has_found_entries 349 | { 350 | return true; 351 | } 352 | 353 | // 2. Check empty lines 354 | if trimmed_line.is_empty() 355 | { 356 | *consecutive_empty_lines = 357 | consecutive_empty_lines.saturating_add(1); 358 | if *consecutive_empty_lines >= 5 && has_found_entries 359 | { 360 | return true; 361 | } 362 | } 363 | else 364 | { 365 | *consecutive_empty_lines = 0; 366 | } 367 | 368 | // 3. Check timeout for entries 369 | if has_found_entries 370 | { 371 | // Reset counter when we have found entries 372 | *lines_without_entries = 0; 373 | } 374 | else 375 | { 376 | *lines_without_entries = lines_without_entries.saturating_add(1); 377 | if *lines_without_entries > 30 378 | { 379 | return true; 380 | } 381 | } 382 | 383 | false 384 | } 385 | 386 | /// Try to extract a `ToC` entry from a line 387 | /// 388 | /// # Arguments 389 | /// 390 | /// * `trimmed_line` - The trimmed line to check 391 | /// * `lines` - The lines of the document 392 | /// * `index` - The index of the line 393 | /// 394 | /// # Returns 395 | /// 396 | /// A `TocEntry` instance representing the extracted entry, or `None` if no 397 | /// entry is found. 398 | fn try_extract_entry( 399 | trimmed_line: &str, 400 | lines: Lines<'_>, 401 | index: LineNumber, 402 | ) -> Option 403 | { 404 | for entry_regex in TOC_ENTRY_PATTERNS.iter() 405 | { 406 | if let Some(caps) = entry_regex.captures(trimmed_line) 407 | { 408 | // Ensure the regex captures both the section number and the 409 | // title 410 | if caps.len() >= 3 411 | { 412 | let section_num = caps[1].trim(); 413 | let title = caps[2].trim(); 414 | 415 | // Find actual section in document 416 | let section_pattern = format!( 417 | r"^\s*{}\s+{}", 418 | regex::escape(section_num), 419 | regex::escape(title) 420 | ); 421 | 422 | if let Ok(section_regex) = Regex::new(§ion_pattern) 423 | { 424 | // Look for the section in the document after the ToC 425 | #[expect( 426 | clippy::arithmetic_side_effects, 427 | reason = "LineNumber not expected to overflow" 428 | )] 429 | for (line_number, doc_line) in 430 | lines.enumerate().skip(index + 1) 431 | { 432 | if section_regex.is_match(doc_line) 433 | { 434 | return Some(TocEntry { 435 | title: format!("{section_num} {title}") 436 | .into(), 437 | line_number, 438 | }); 439 | } 440 | } 441 | } 442 | } 443 | break; // Stop checking patterns if one matched 444 | } 445 | } 446 | None 447 | } 448 | 449 | /// Parses the document content heuristically to extract a table of 450 | /// contents. 451 | /// 452 | /// Identifies section headers in RFC format (e.g., "1. Introduction") and 453 | /// capitalized headings as `ToC` entries. 454 | /// 455 | /// # Arguments 456 | /// 457 | /// * `content` - The document content to parse 458 | /// 459 | /// # Returns 460 | /// 461 | /// A vector of `TocEntry` instances representing the document's structure 462 | /// 463 | /// # Warning 464 | /// 465 | /// This function is not guaranteed to work correctly for all documents. 466 | /// It is intended to be used as a last resort when no existing `ToC` is 467 | /// found. 468 | fn parse_toc_heuristic(content: &str) -> Vec 469 | { 470 | let mut entries = Vec::new(); 471 | let mut section_pattern = false; 472 | 473 | for (line_number, line) in content.lines().enumerate() 474 | { 475 | let line = line.trim_end(); 476 | 477 | // Check for section headers in typical RFC format 478 | if line.starts_with(|ch: char| ch.is_ascii_digit()) && 479 | line.contains('.') 480 | { 481 | let parts: Vec<&str> = line.splitn(2, '.').collect(); 482 | if parts.len() == 2 && !parts[0].contains(' ') 483 | { 484 | entries.push(TocEntry { 485 | title: line.into(), 486 | line_number, 487 | }); 488 | section_pattern = true; 489 | } 490 | } 491 | // If we didn't find standard section patterns, look for capitalized 492 | // headings 493 | else if !section_pattern && 494 | line.len() > 3 && 495 | line == line.to_uppercase() 496 | { 497 | entries.push(TocEntry { 498 | title: line.into(), 499 | line_number, 500 | }); 501 | } 502 | } 503 | 504 | entries 505 | } 506 | 507 | /// Parses the document to extract a table of contents. 508 | /// 509 | /// # Arguments 510 | /// 511 | /// * `content` - The document content to parse 512 | /// 513 | /// # Returns 514 | /// 515 | /// A vector of `TocEntry` instances representing the document's structure 516 | pub fn parse_toc(content: &str) -> Vec 517 | { 518 | // First, look for existing ToC. Otherwise, use heuristic. 519 | parse_toc_existing(content) 520 | .unwrap_or_else(|| parse_toc_heuristic(content)) 521 | } 522 | } 523 | -------------------------------------------------------------------------------- /src/ui/app.rs: -------------------------------------------------------------------------------- 1 | //! Core application logic and app state management. 2 | //! 3 | //! Provides the central application state and handles UI rendering and user 4 | //! input. This includes features such as document scrolling, searching, 5 | //! and navigation. 6 | use std::borrow::Cow; 7 | use std::collections::HashMap; 8 | use std::io::stdout; 9 | use std::ops::Range; 10 | 11 | use bitflags::bitflags; 12 | use cached::proc_macro::cached; 13 | use crossterm::cursor::{Hide, Show}; 14 | use crossterm::execute; 15 | use crossterm::terminal::{SetTitle, size}; 16 | use log::warn; 17 | use ratatui::Frame; 18 | use ratatui::layout::{Alignment, Constraint, Direction, Flex, Layout, Rect}; 19 | use ratatui::style::{Color, Modifier, Style}; 20 | use ratatui::text::{Line, Span, Text}; 21 | use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; 22 | use regex::Regex; 23 | 24 | use super::guard::TerminalGuard; 25 | use super::toc_panel::TocPanel; 26 | 27 | /// Style for highlighting matches in the search results. 28 | const MATCH_HIGHLIGHT_STYLE: Style = Style::new() 29 | .fg(Color::Yellow) 30 | .add_modifier(Modifier::BOLD); 31 | 32 | /// Style for highlighting titles in the document. 33 | const TITLE_HIGHLIGHT_STYLE: Style = Style::new() 34 | .fg(Color::Cyan) 35 | .add_modifier(Modifier::BOLD); 36 | 37 | /// Style for the statusbar. 38 | const STATUSBAR_STYLE: Style = Style::new() 39 | .bg(Color::White) 40 | .fg(Color::Black); 41 | 42 | // UI constants 43 | /// Minimum terminal width in columns for proper UI rendering. 44 | // Note: Gotta review when the text are edited. 45 | const MIN_TERMINAL_WIDTH: u16 = 94; 46 | /// Minimum terminal height in rows for proper UI rendering. 47 | const MIN_TERMINAL_HEIGHT: u16 = 15; 48 | 49 | // ToC/content split percentages. 50 | /// 1/4 for `ToC`, 3/4 for content 51 | const TOC_PERCENTAGE: u16 = 25; 52 | /// Constraints for the `ToC`/content split. 53 | const TOC_SPLIT_CONSTRAINTS: [Constraint; 2] = [ 54 | Constraint::Percentage(TOC_PERCENTAGE), 55 | Constraint::Percentage(100 - TOC_PERCENTAGE), 56 | ]; 57 | 58 | /// Application mode for the current UI state. 59 | /// 60 | /// Controls what is displayed and how the user input is interpreted. 61 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 62 | pub enum AppMode 63 | { 64 | /// Normal reading mode, default state 65 | Normal, 66 | /// Help overlay being displayed 67 | Help, 68 | /// Search mode, accepting search input 69 | Search, 70 | } 71 | 72 | bitflags! { 73 | /// Flags indicating the current state of the application. 74 | #[derive(Debug)] 75 | pub struct AppStateFlags: u8 76 | { 77 | /// Application should continue running 78 | const SHOULD_RUN = 1; 79 | /// Whether table of contents should be displayed 80 | const SHOULD_SHOW_TOC = 1 << 1; 81 | /// Whether search yields no results 82 | const HAS_NO_RESULTS = 1 << 2; 83 | /// Are we searching case-sensitively? 84 | const IS_CASE_SENSITIVE = 1 << 3; 85 | /// Are we searching with regex? 86 | const IS_USING_REGEX = 1 << 4; 87 | } 88 | } 89 | 90 | impl Default for AppStateFlags 91 | { 92 | fn default() -> Self 93 | { 94 | Self::SHOULD_RUN 95 | } 96 | } 97 | 98 | /// Type alias for line numbers. 99 | pub(super) type LineNumber = usize; 100 | 101 | /// Type alias for matches spanning a line. 102 | type MatchSpan = Range; 103 | 104 | /// Manages the core state and UI logic. 105 | /// 106 | /// This includes rendering the document, processing user input, and handling 107 | /// interactions like scrolling, searching, navigation and graceful shutdown. 108 | pub struct App 109 | { 110 | // Core document 111 | /// Content of the currently loaded RFC 112 | pub rfc_content: Box, 113 | /// Number of the currently loaded RFC 114 | pub rfc_number: u16, 115 | /// Table of contents panel for the current document 116 | pub rfc_toc_panel: TocPanel, 117 | /// Total line number of the content 118 | pub rfc_line_number: LineNumber, 119 | 120 | // Navigation 121 | /// Current scroll position in the document 122 | pub current_scroll_pos: LineNumber, 123 | 124 | // UI state 125 | /// Current application mode 126 | pub mode: AppMode, 127 | /// Flags for managing the application state. 128 | pub app_state: AppStateFlags, 129 | /// Handle graceful terminal shutdown. 130 | #[allow( 131 | dead_code, 132 | reason = "Its purpose is its `Drop` implementation, not direct field \ 133 | access." 134 | )] 135 | guard: TerminalGuard, 136 | 137 | // Search 138 | /// Text of the query to search. 139 | pub query_text: String, 140 | /// Cursor position in the search text (byte index) 141 | pub query_cursor_pos: usize, 142 | /// Line numbers where query matches were found. 143 | pub query_match_line_nums: Vec, 144 | /// Index of the currently selected query match. 145 | pub current_query_match_index: LineNumber, 146 | /// Line numbers and their positions of query matches. 147 | pub query_matches: HashMap>, 148 | } 149 | 150 | impl App 151 | { 152 | /// Creates a new App instance with the specified RFC. 153 | /// 154 | /// # Arguments 155 | /// 156 | /// * `rfc_number` - The RFC number of the document 157 | /// * `content` - The content of the RFC document 158 | /// 159 | /// # Returns 160 | /// 161 | /// A new `App` instance initialized for the specified RFC 162 | #[must_use] 163 | pub fn new(rfc_number: u16, rfc_content: Box) -> Self 164 | { 165 | let rfc_toc_panel = TocPanel::new(&rfc_content); 166 | let rfc_line_number = rfc_content.lines().count(); 167 | 168 | let title = format!("RFC {rfc_number} - Press ? for help"); 169 | if let Err(error) = execute!(stdout(), SetTitle(title)) 170 | { 171 | warn!("Couldn't set the window title: {error}"); 172 | } 173 | 174 | Self { 175 | rfc_content, 176 | rfc_number, 177 | rfc_toc_panel, 178 | rfc_line_number, 179 | ..Default::default() 180 | } 181 | } 182 | 183 | /// Checks if the terminal is too small. 184 | /// 185 | /// # Returns 186 | /// 187 | /// A boolean indicating if the terminal is too small. 188 | fn is_terminal_too_small() -> bool 189 | { 190 | let (current_width, current_height) = 191 | size().expect("Couldn't get terminal size"); 192 | 193 | current_width < MIN_TERMINAL_WIDTH || 194 | current_height < MIN_TERMINAL_HEIGHT 195 | } 196 | 197 | /// Builds the RFC text with highlighting for search matches and titles. 198 | fn build_text(&self) -> Text<'_> 199 | { 200 | // Check if we need search highlighting 201 | let has_searched = 202 | self.mode == AppMode::Search || !self.query_text.is_empty(); 203 | 204 | let lines: Vec = self 205 | .rfc_content 206 | .lines() 207 | .enumerate() 208 | .map(|(line_num, line_str)| { 209 | let is_title = self.rfc_toc_panel 210 | .entries() 211 | .binary_search_by(|entry| entry.line_number.cmp(&line_num)) 212 | .is_ok(); 213 | 214 | if has_searched 215 | { 216 | // Highlight search match 217 | if let Some(matches) = self.query_matches.get(&line_num) 218 | { 219 | return Self::build_line_with_search_and_title_highlights( 220 | line_str, matches, is_title, 221 | ); 222 | } 223 | } 224 | 225 | if is_title 226 | { 227 | // Only title highlighting 228 | Line::from(Span::styled(line_str, TITLE_HIGHLIGHT_STYLE)) 229 | } 230 | else 231 | { 232 | // No highlighting 233 | Line::from(line_str) 234 | } 235 | }) 236 | .collect(); 237 | 238 | Text::from(lines) 239 | } 240 | 241 | /// Builds a line with both search and title highlighting. 242 | /// 243 | /// # Arguments 244 | /// 245 | /// * `line_str` - The line content 246 | /// * `matches` - Search match spans in the line 247 | /// * `is_title` - Whether this line is a title 248 | /// 249 | /// # Returns 250 | /// 251 | /// A `Line` with appropriate highlighting applied 252 | fn build_line_with_search_and_title_highlights<'line_str>( 253 | line_str: &'line_str str, 254 | matches: &[MatchSpan], 255 | is_title: bool, 256 | ) -> Line<'line_str> 257 | { 258 | let mut spans = Vec::new(); 259 | let mut last_end = 0; 260 | 261 | for match_span in matches 262 | { 263 | // Clamp indexes to the line length to avoid out of bounds access 264 | let start = match_span.start.min(line_str.len()); 265 | let end = match_span.end.min(line_str.len()); 266 | 267 | if start > last_end && 268 | let Some(text) = line_str.get(last_end..start) 269 | { 270 | if is_title 271 | { 272 | spans.push(Span::styled(text, TITLE_HIGHLIGHT_STYLE)); 273 | } 274 | else 275 | { 276 | spans.push(Span::raw(text)); 277 | } 278 | } 279 | 280 | if let Some(m) = line_str.get(start..end) 281 | { 282 | spans.push(Span::styled(m, MATCH_HIGHLIGHT_STYLE)); 283 | } 284 | 285 | last_end = end; 286 | } 287 | 288 | // Add remaining text after the last match 289 | if last_end < line_str.len() && 290 | let Some(text) = line_str.get(last_end..) 291 | { 292 | if is_title 293 | { 294 | spans.push(Span::styled(text, TITLE_HIGHLIGHT_STYLE)); 295 | } 296 | else 297 | { 298 | spans.push(Span::raw(text)); 299 | } 300 | } 301 | 302 | Line::from(spans) 303 | } 304 | 305 | /// Renders the application UI to the provided frame. 306 | /// 307 | /// # Arguments 308 | /// 309 | /// * `frame` - The frame to render the UI to 310 | /// 311 | /// # Panics 312 | /// 313 | /// Panics if the frame cannot be rendered. 314 | pub fn render(&mut self, frame: &mut Frame) 315 | { 316 | /// Height of the status bar in rows. 317 | const STATUSBAR_HEIGHT_CONSTRAINT: Constraint = Constraint::Length(1); 318 | 319 | if Self::is_terminal_too_small() 320 | { 321 | Self::render_too_small_message(frame); 322 | return; 323 | } 324 | 325 | // Clear the entire frame on each render to prevent artifacts 326 | frame.render_widget(Clear, frame.area()); 327 | 328 | // Create main layout with statusbar at bottom 329 | let [main_area, statusbar_area] = Layout::default() 330 | .direction(Direction::Vertical) 331 | .constraints([ 332 | Constraint::Min(0), // Main content takes remaining space 333 | STATUSBAR_HEIGHT_CONSTRAINT, 334 | ]) 335 | .areas(frame.area()); 336 | 337 | let (content_area, toc_area) = if self 338 | .app_state 339 | .contains(AppStateFlags::SHOULD_SHOW_TOC) 340 | { 341 | // Create layout with ToC panel on the left 342 | let [toc_area, content_area] = Layout::default() 343 | .direction(Direction::Horizontal) 344 | .constraints(TOC_SPLIT_CONSTRAINTS) 345 | .areas(main_area); 346 | 347 | (content_area, Some(toc_area)) 348 | } 349 | else 350 | { 351 | (main_area, None) 352 | }; 353 | 354 | if let Some(toc_area) = toc_area 355 | { 356 | // Render ToC in the left area 357 | self.rfc_toc_panel.render(frame, toc_area); 358 | } 359 | 360 | // Render the text with highlights if in search mode or if there is a 361 | // search text 362 | let text = self.build_text(); 363 | 364 | // Clamp the scroll position instead of panicking 365 | let y = u16::try_from(self.current_scroll_pos).unwrap_or(u16::MAX); 366 | let paragraph = Paragraph::new(text).scroll((y, 0)); 367 | 368 | // Rendering the paragraph happens here 369 | frame.render_widget(paragraph, content_area); 370 | 371 | // Render statusbar 372 | self.render_statusbar(frame, statusbar_area); 373 | 374 | // Render help if in help mode 375 | if self.mode == AppMode::Help 376 | { 377 | Self::render_help(frame); 378 | } 379 | 380 | // Render search if in search mode 381 | if self.mode == AppMode::Search 382 | { 383 | self.render_search(frame); 384 | } 385 | 386 | // Render no search message 387 | if self 388 | .app_state 389 | .contains(AppStateFlags::HAS_NO_RESULTS) 390 | { 391 | Self::render_no_search_results(frame); 392 | } 393 | } 394 | 395 | /// Renders the help overlay with keyboard shortcuts. 396 | /// 397 | /// # Arguments 398 | /// 399 | /// * `frame` - The frame to render the help overlay to 400 | fn render_help(frame: &mut Frame) 401 | { 402 | /// Help overlay box width as percentage of the terminal width. 403 | const HELP_OVERLAY_WIDTH_CONSTRAINT: Constraint = 404 | Constraint::Percentage(60); 405 | /// Help overlay box height as percentage of the terminal height. 406 | const HELP_OVERLAY_HEIGHT_CONSTRAINT: Constraint = 407 | Constraint::Percentage(65); 408 | 409 | // Create a centered rectangle. 410 | let area = centered_rect( 411 | frame.area(), 412 | HELP_OVERLAY_WIDTH_CONSTRAINT, 413 | HELP_OVERLAY_HEIGHT_CONSTRAINT, 414 | ); 415 | 416 | // Clear the area first to make it fully opaque 417 | frame.render_widget(Clear, area); 418 | 419 | let text = Text::from(vec![ 420 | Line::from("Keybindings:"), 421 | Line::from(""), 422 | // Vim-like navigation 423 | Line::from("j/k or ↓/↑: Scroll down/up"), 424 | Line::from("f/b or PgDn/PgUp: Scroll page down/up"), 425 | Line::from("g/G: Go to start/end of document"), 426 | Line::from(""), 427 | Line::from("t: Toggle table of contents"), 428 | Line::from("w/s: Navigate ToC up/down"), 429 | Line::from("Enter: Jump to ToC entry"), 430 | Line::from(""), 431 | Line::from("/: Search"), 432 | Line::from("n/N: Next/previous search result"), 433 | Line::from("Ctrl+C: Toggle case sensitivity"), 434 | Line::from("Ctrl+R: Toggle regex search"), 435 | Line::from("Esc: Reset search highlights"), 436 | Line::from(""), 437 | Line::from("q: Quit"), 438 | Line::from("?: Toggle help"), 439 | ]); 440 | 441 | let help_box = Paragraph::new(text) 442 | .block( 443 | Block::default() 444 | .borders(Borders::ALL) 445 | .title("RFC Reader Help") 446 | .title_alignment(Alignment::Center) 447 | .style(Style::default()), 448 | ) 449 | .style(Style::default()) 450 | .wrap(Wrap { trim: true }); 451 | 452 | // Put the help box in it. 453 | frame.render_widget(help_box, area); 454 | } 455 | 456 | /// Renders the search input box. 457 | /// 458 | /// # Arguments 459 | /// 460 | /// * `frame` - The frame to render the search box to 461 | fn render_search(&self, frame: &mut Frame) 462 | { 463 | /// Search prompt prefix. 464 | const SEARCH_PROMPT: &str = "/"; 465 | /// Prefix length for the search prompt ("/"). 466 | #[allow( 467 | clippy::cast_possible_truncation, 468 | reason = "Terminal width is excpected to fit in u16 bounds" 469 | )] 470 | const SEARCH_PREFIX_LENGTH: u16 = SEARCH_PROMPT.len() as _; 471 | /// Search box height in rows. 472 | const SEARCH_BOX_HEIGHT_ROWS: u16 = 3; 473 | /// Horizontal start position divisor (x = width / 474 | /// `SEARCH_BOX_X_DIVISOR`). 475 | const SEARCH_BOX_X_DIVISOR: u16 = 4; 476 | /// Box width divisor (`box_width` = width / 477 | /// `SEARCH_BOX_WIDTH_DIVISOR`). 478 | const SEARCH_BOX_WIDTH_DIVISOR: u16 = 2; 479 | /// Distance from bottom in rows. 480 | const SEARCH_BOX_BOTTOM_OFFSET_ROWS: u16 = 4; 481 | /// Border width for cursor position calculation. 482 | const SEARCH_BOX_BORDER_WIDTH: u16 = 1; 483 | 484 | let area = Rect::new( 485 | frame.area().width / SEARCH_BOX_X_DIVISOR, 486 | frame 487 | .area() 488 | .height 489 | .saturating_sub(SEARCH_BOX_BOTTOM_OFFSET_ROWS), 490 | frame.area().width / SEARCH_BOX_WIDTH_DIVISOR, 491 | SEARCH_BOX_HEIGHT_ROWS, 492 | ); 493 | 494 | // Clear the area first to make it fully opaque 495 | frame.render_widget(Clear, area); 496 | 497 | let text = Text::from(format!("{}{}", SEARCH_PROMPT, self.query_text)); 498 | 499 | let search_box = Paragraph::new(text) 500 | .block( 501 | Block::default() 502 | .borders(Borders::ALL) 503 | .title("Search") 504 | .style(Style::default()), 505 | ) 506 | .style(Style::default()); 507 | 508 | frame.render_widget(search_box, area); 509 | 510 | // Calculate cursor position 511 | // The cursor should be after the "/" prefix and at the current position 512 | // in the query text 513 | let cursor_x = area 514 | .x 515 | .saturating_add(SEARCH_BOX_BORDER_WIDTH) 516 | .saturating_add(SEARCH_PREFIX_LENGTH) 517 | .saturating_add( 518 | self.query_text 519 | .get(..self.query_cursor_pos) 520 | .map_or(0, |before_cursor| before_cursor.chars().count()) 521 | .try_into() 522 | .unwrap_or(0), 523 | ); 524 | let cursor_y = area 525 | .y 526 | .saturating_add(SEARCH_BOX_BORDER_WIDTH); 527 | 528 | // Set cursor position 529 | frame.set_cursor_position((cursor_x, cursor_y)); 530 | } 531 | 532 | /// Renders the no search results message. 533 | /// 534 | /// # Arguments 535 | /// 536 | /// * `frame` - The frame to render the no search results message to 537 | fn render_no_search_results(frame: &mut Frame) 538 | { 539 | /// No-search-results overlay width as percentage of the terminal width. 540 | const NO_SEARCH_OVERLAY_WIDTH_CONSTRAINT: Constraint = 541 | Constraint::Percentage(40); 542 | /// No-search-results overlay height percentage. 543 | const NO_SEARCH_OVERLAY_HEIGHT_CONSTRAINT: Constraint = 544 | Constraint::Percentage(25); 545 | /// No-search-results overlay title text. 546 | const NO_SEARCH_TITLE: &str = "No matches - Press Esc to dismiss"; 547 | /// No-search-results overlay message text. 548 | const NO_SEARCH_MESSAGE: &str = "Search yielded nothing"; 549 | 550 | let area = centered_rect( 551 | frame.area(), 552 | NO_SEARCH_OVERLAY_WIDTH_CONSTRAINT, 553 | NO_SEARCH_OVERLAY_HEIGHT_CONSTRAINT, 554 | ); 555 | 556 | // Clear the area first to make it fully opaque 557 | frame.render_widget(Clear, area); 558 | 559 | let text = Text::raw(NO_SEARCH_MESSAGE); 560 | 561 | let no_search_box = Paragraph::new(text) 562 | .block( 563 | Block::default() 564 | .title(NO_SEARCH_TITLE) 565 | .borders(Borders::ALL) 566 | .style(Style::default().fg(Color::Red)), 567 | ) 568 | .alignment(Alignment::Center) 569 | .style(Style::default()); 570 | 571 | frame.render_widget(no_search_box, area); 572 | } 573 | 574 | /// Renders the too small message. 575 | /// 576 | /// The message is displayed when the terminal is too small to display 577 | /// the application. 578 | /// 579 | /// # Arguments 580 | /// 581 | /// * `frame` - The frame to render the too small message to 582 | fn render_too_small_message(frame: &mut Frame) 583 | { 584 | /// "Terminal too small" overlay height as percentage of the terminal 585 | /// height. 586 | const TOO_SMALL_OVERLAY_HEIGHT_CONSTRAINT: Constraint = 587 | Constraint::Percentage(50); 588 | /// "Terminal too small" overlay title text. 589 | const TOO_SMALL_ERROR_TEXT: &str = "Terminal size is too small:"; 590 | 591 | let (current_width, current_height) = 592 | size().expect("Couldn't get terminal size"); 593 | 594 | // Determine colors based on whether dimensions meet requirements 595 | let current_width_color = if current_width >= MIN_TERMINAL_WIDTH 596 | { 597 | Color::Green 598 | } 599 | else 600 | { 601 | Color::Red 602 | }; 603 | 604 | let current_height_color = if current_height >= MIN_TERMINAL_HEIGHT 605 | { 606 | Color::Green 607 | } 608 | else 609 | { 610 | Color::Red 611 | }; 612 | 613 | // Clear the area first to make it fully opaque 614 | frame.render_widget(Clear, frame.area()); 615 | 616 | let area = centered_rect( 617 | frame.area(), 618 | Constraint::Min( 619 | TOO_SMALL_ERROR_TEXT 620 | .len() 621 | .try_into() 622 | .expect("TOO_SMALL_ERROR_TEXT length too big to cast"), 623 | ), 624 | TOO_SMALL_OVERLAY_HEIGHT_CONSTRAINT, 625 | ); 626 | 627 | let text = Text::from(vec![ 628 | Line::from(TOO_SMALL_ERROR_TEXT), 629 | Line::from(vec![ 630 | Span::raw("Width: "), 631 | Span::styled( 632 | format!("{current_width}"), 633 | Style::default() 634 | .fg(current_width_color) 635 | .add_modifier(Modifier::BOLD), 636 | ), 637 | Span::raw(", "), 638 | Span::raw("Height: "), 639 | Span::styled( 640 | format!("{current_height}"), 641 | Style::default() 642 | .fg(current_height_color) 643 | .add_modifier(Modifier::BOLD), 644 | ), 645 | ]), 646 | Line::from(""), 647 | Line::from("Minimum required:"), 648 | Line::from(vec![ 649 | Span::raw("Width: "), 650 | Span::styled( 651 | format!("{MIN_TERMINAL_WIDTH}"), 652 | Style::default() 653 | .fg(Color::White) 654 | .add_modifier(Modifier::BOLD), 655 | ), 656 | Span::raw(", "), 657 | Span::raw("Height: "), 658 | Span::styled( 659 | format!("{MIN_TERMINAL_HEIGHT}"), 660 | Style::default() 661 | .fg(Color::White) 662 | .add_modifier(Modifier::BOLD), 663 | ), 664 | ]), 665 | ]); 666 | 667 | let paragraph = Paragraph::new(text).alignment(Alignment::Center); 668 | 669 | frame.render_widget(paragraph, area); 670 | } 671 | 672 | /// Renders the statusbar with current status. 673 | /// 674 | /// # Arguments 675 | /// 676 | /// * `frame` - The frame to render the statusbar to 677 | /// * `area` - The area to render the statusbar in 678 | fn render_statusbar(&self, frame: &mut Frame, area: Rect) 679 | { 680 | /// Statusbar left section minimum width in columns. 681 | const STATUSBAR_LEFT_MIN_COLS: u16 = 40; 682 | /// Statusbar middle section minimum width in columns. 683 | const STATUSBAR_MIDDLE_MIN_COLS: u16 = 0; // takes remaining space 684 | /// Statusbar right section minimum width in columns. 685 | const STATUSBAR_RIGHT_MIN_COLS: u16 = 42; 686 | 687 | let [left_section, middle_section, right_section] = Layout::default() 688 | .direction(Direction::Horizontal) 689 | .constraints([ 690 | Constraint::Min(STATUSBAR_LEFT_MIN_COLS), 691 | Constraint::Min(STATUSBAR_MIDDLE_MIN_COLS), // Middle takes remaining space 692 | Constraint::Min(STATUSBAR_RIGHT_MIN_COLS), 693 | ]) 694 | .flex(Flex::SpaceBetween) 695 | .areas(area); 696 | 697 | // Left section 698 | let progress_text = self.build_progress_text(); 699 | let left_text = format!("RFC {} | {}", self.rfc_number, progress_text); 700 | let left_statusbar = Paragraph::new(left_text) 701 | .style(STATUSBAR_STYLE) 702 | .alignment(Alignment::Left); 703 | frame.render_widget(left_statusbar, left_section); 704 | 705 | // Middle section 706 | let mode_text = self.get_mode_text(); 707 | let middle_statusbar = Paragraph::new(mode_text) 708 | .style(STATUSBAR_STYLE) 709 | .alignment(Alignment::Center); 710 | frame.render_widget(middle_statusbar, middle_section); 711 | 712 | // Right section 713 | let help_text = self.get_help_text(); 714 | let right_statusbar = Paragraph::new(help_text) 715 | .style(STATUSBAR_STYLE) 716 | .alignment(Alignment::Right); 717 | frame.render_widget(right_statusbar, right_section); 718 | } 719 | 720 | /// Builds the mode text representation for the statusbar. 721 | /// 722 | /// # Returns 723 | /// 724 | /// A string containing the current mode. 725 | fn get_mode_text(&self) -> Cow<'static, str> 726 | { 727 | match self.mode 728 | { 729 | AppMode::Normal 730 | if self 731 | .app_state 732 | .contains(AppStateFlags::SHOULD_SHOW_TOC) => 733 | { 734 | Cow::Borrowed("NORMAL (ToC)") 735 | }, 736 | AppMode::Normal => Cow::Borrowed("NORMAL"), 737 | AppMode::Help => Cow::Borrowed("HELP"), 738 | AppMode::Search => Cow::Owned(self.get_search_mode_text()), 739 | } 740 | } 741 | 742 | /// Builds the search mode text for the statusbar. 743 | /// Includes case sensitivity and regex flags. 744 | /// 745 | /// # Returns 746 | /// 747 | /// A string containing the search mode text. 748 | fn get_search_mode_text(&self) -> String 749 | { 750 | const EMPTY_BOX_CHAR: char = '☐'; 751 | const CHECKED_BOX_CHAR: char = '☑'; 752 | 753 | let case_char = if self 754 | .app_state 755 | .contains(AppStateFlags::IS_CASE_SENSITIVE) 756 | { 757 | CHECKED_BOX_CHAR 758 | } 759 | else 760 | { 761 | EMPTY_BOX_CHAR 762 | }; 763 | 764 | let regex_char = if self 765 | .app_state 766 | .contains(AppStateFlags::IS_USING_REGEX) 767 | { 768 | CHECKED_BOX_CHAR 769 | } 770 | else 771 | { 772 | EMPTY_BOX_CHAR 773 | }; 774 | 775 | format!("SEARCH | C:{case_char} R:{regex_char}") 776 | } 777 | 778 | /// Builds the progress text for the statusbar. 779 | /// 780 | /// # Returns 781 | /// 782 | /// A string containing the current line number, total lines, progress 783 | /// percentage, and search information. 784 | #[expect( 785 | clippy::arithmetic_side_effects, 786 | reason = "LineNumber not expected to overflow" 787 | )] 788 | fn build_progress_text(&self) -> String 789 | { 790 | let progress_percentage = if self.rfc_line_number > 0 791 | { 792 | (self.current_scroll_pos * 100) / self.rfc_line_number 793 | } 794 | else 795 | { 796 | 0 797 | }; 798 | 799 | let search_info = self.build_search_info().unwrap_or_default(); 800 | 801 | format!( 802 | "L {}/{} ({}%){}", 803 | self.current_scroll_pos + 1, 804 | self.rfc_line_number, 805 | progress_percentage, 806 | search_info 807 | ) 808 | } 809 | 810 | /// Builds the search info text for the statusbar. 811 | /// This includes the current match index and total match count. 812 | /// 813 | /// # Returns 814 | /// 815 | /// An `Option` containing the search info if there are matches, 816 | /// or `None` if there are no matches or the query is empty. 817 | #[expect( 818 | clippy::arithmetic_side_effects, 819 | reason = "LineNumber not expected to overflow" 820 | )] 821 | fn build_search_info(&self) -> Option 822 | { 823 | if !self.has_search_results() 824 | { 825 | return None; 826 | } 827 | 828 | let total_matches_n: LineNumber = self.query_match_line_nums.len(); 829 | // Clamp index to last valid match 830 | let index: LineNumber = self 831 | .current_query_match_index 832 | .min(total_matches_n.saturating_sub(1)); 833 | 834 | Some(format!(" | M {}/{}", index + 1, total_matches_n)) 835 | } 836 | 837 | /// Builds the help text for the statusbar. 838 | /// Helps the user understand available commands. 839 | /// 840 | /// # Returns 841 | /// 842 | /// A string containing the help text for the statusbar. 843 | const fn get_help_text(&self) -> &'static str 844 | { 845 | match (self.mode, self.has_search_results()) 846 | { 847 | (AppMode::Normal, _) 848 | if self 849 | .app_state 850 | .contains(AppStateFlags::SHOULD_SHOW_TOC) => 851 | { 852 | "t:toggle ToC w/s:nav Enter:jump q:quit" 853 | }, 854 | (AppMode::Normal, true) => "n/N:next/prev Esc:clear", 855 | (AppMode::Normal, false) => 856 | { 857 | "up/down:scroll /:search ?:help q:quit" 858 | }, 859 | (AppMode::Help, _) => "?/Esc:close", 860 | (AppMode::Search, _) => "Enter:search Esc:cancel", 861 | } 862 | } 863 | 864 | /// Scrolls the document up by the specified amount. 865 | /// 866 | /// # Arguments 867 | /// 868 | /// * `amount` - Number of lines to scroll up 869 | pub const fn scroll_up(&mut self, amount: LineNumber) 870 | { 871 | // Don't allow wrapping, once we reach the top, stay there. 872 | self.current_scroll_pos = self 873 | .current_scroll_pos 874 | .saturating_sub(amount); 875 | } 876 | 877 | /// Scrolls the document down by the specified amount. 878 | /// 879 | /// # Arguments 880 | /// 881 | /// * `amount` - Number of lines to scroll down 882 | pub fn scroll_down(&mut self, amount: LineNumber) 883 | { 884 | let last_line_pos = self.rfc_line_number.saturating_sub(1); 885 | // Clamp the scroll position to the last line. 886 | // Once we reach the bottom, stay there. 887 | self.current_scroll_pos = (self 888 | .current_scroll_pos 889 | .saturating_add(amount)) 890 | .min(last_line_pos); 891 | } 892 | 893 | /// Jumps to the current `ToC` entry by scrolling to its line. 894 | /// 895 | /// If no entry is selected, does nothing. 896 | pub fn jump_to_toc_entry(&mut self) 897 | { 898 | if let Some(line_num) = self.rfc_toc_panel.selected_line() 899 | { 900 | self.current_scroll_pos = line_num; 901 | } 902 | } 903 | 904 | /// Toggles the help overlay. 905 | pub fn toggle_help(&mut self) 906 | { 907 | self.mode = if self.mode == AppMode::Help 908 | { 909 | AppMode::Normal 910 | } 911 | else 912 | { 913 | AppMode::Help 914 | }; 915 | } 916 | 917 | /// Toggles the table of contents panel. 918 | /// 919 | /// If the panel is shown, it will be hidden, and vice versa. 920 | pub fn toggle_toc(&mut self) 921 | { 922 | self.app_state 923 | .toggle(AppStateFlags::SHOULD_SHOW_TOC); 924 | } 925 | 926 | /// Toggles case sensitivity for searches. 927 | /// 928 | /// If case sensitivity is enabled, searches will be case-sensitive. 929 | /// If disabled, searches will be case-insensitive. 930 | pub fn toggle_case_sensitivity(&mut self) 931 | { 932 | self.app_state 933 | .toggle(AppStateFlags::IS_CASE_SENSITIVE); 934 | } 935 | 936 | /// Toggles regex mode for searches. 937 | /// 938 | /// If regex mode is enabled, searches will interpret the query as a regex 939 | /// pattern. 940 | pub fn toggle_regex_mode(&mut self) 941 | { 942 | self.app_state 943 | .toggle(AppStateFlags::IS_USING_REGEX); 944 | } 945 | 946 | /// Enters search mode, clearing any previous search. 947 | pub fn enter_search_mode(&mut self) 948 | { 949 | self.mode = AppMode::Search; 950 | self.query_text.clear(); // Start with an empty search 951 | self.query_cursor_pos = 0; 952 | 953 | // Show cursor when entering search mode 954 | if let Err(error) = execute!(stdout(), Show) 955 | { 956 | warn!("Failed to show cursor: {error}"); 957 | } 958 | } 959 | 960 | /// Exits search mode and returns to normal mode. 961 | pub fn exit_search_mode(&mut self) 962 | { 963 | self.mode = AppMode::Normal; 964 | 965 | // Hide cursor when exiting search mode 966 | if let Err(error) = execute!(stdout(), Hide) 967 | { 968 | warn!("Failed to hide cursor: {error}"); 969 | } 970 | } 971 | 972 | /// Checks if there are any search results. 973 | /// 974 | /// # Returns 975 | /// 976 | /// A boolean indicating if there are any search results. 977 | const fn has_search_results(&self) -> bool 978 | { 979 | !self.query_text.is_empty() && !self.query_match_line_nums.is_empty() 980 | } 981 | 982 | /// Adds a character to the search text at cursor position. 983 | /// 984 | /// # Arguments 985 | /// 986 | /// * `ch` - The character to add 987 | pub fn add_search_char(&mut self, ch: char) 988 | { 989 | self.query_text 990 | .insert(self.query_cursor_pos, ch); 991 | self.query_cursor_pos = self 992 | .query_cursor_pos 993 | .saturating_add(ch.len_utf8()); 994 | } 995 | 996 | /// Removes the character before the cursor in the search text. 997 | pub fn remove_search_char(&mut self) 998 | { 999 | if self.query_cursor_pos > 0 1000 | { 1001 | self.move_search_cursor_left(); 1002 | self.delete_search_char(); 1003 | } 1004 | } 1005 | 1006 | /// Deletes the character front of the cursor in the search text. 1007 | pub fn delete_search_char(&mut self) 1008 | { 1009 | if self.query_cursor_pos < self.query_text.len() 1010 | { 1011 | self.query_text.remove(self.query_cursor_pos); 1012 | } 1013 | } 1014 | 1015 | /// Moves the search cursor left by one character. 1016 | pub fn move_search_cursor_left(&mut self) 1017 | { 1018 | if self.query_cursor_pos > 0 1019 | { 1020 | // Find the previous character boundary 1021 | let mut pos = self.query_cursor_pos.saturating_sub(1); 1022 | while pos > 0 && !self.query_text.is_char_boundary(pos) 1023 | { 1024 | pos = pos.saturating_sub(1); 1025 | } 1026 | self.query_cursor_pos = pos; 1027 | } 1028 | } 1029 | 1030 | /// Moves the search cursor right by one character. 1031 | pub fn move_search_cursor_right(&mut self) 1032 | { 1033 | if self.query_cursor_pos < self.query_text.len() 1034 | { 1035 | let mut pos = self.query_cursor_pos.saturating_add(1); 1036 | while pos < self.query_text.len() && 1037 | !self.query_text.is_char_boundary(pos) 1038 | { 1039 | pos = pos.saturating_add(1); 1040 | } 1041 | self.query_cursor_pos = pos; 1042 | } 1043 | } 1044 | 1045 | /// Moves the search cursor to the start of the text. 1046 | pub const fn move_search_cursor_home(&mut self) 1047 | { 1048 | self.query_cursor_pos = 0; 1049 | } 1050 | 1051 | /// Moves the search cursor to the end of the text. 1052 | pub const fn move_search_cursor_end(&mut self) 1053 | { 1054 | self.query_cursor_pos = self.query_text.len(); 1055 | } 1056 | 1057 | /// Performs a search using the current search text. 1058 | /// 1059 | /// Finds all occurrences of the search text in the RFC content 1060 | /// and stores the results. If results are found, jumps to the 1061 | /// first result starting from the current scroll position. 1062 | pub fn perform_search(&mut self) 1063 | { 1064 | self.query_match_line_nums.clear(); 1065 | self.query_matches.clear(); 1066 | 1067 | if self.query_text.is_empty() 1068 | { 1069 | return; 1070 | } 1071 | 1072 | let is_case_sensitive = self 1073 | .app_state 1074 | .contains(AppStateFlags::IS_CASE_SENSITIVE); 1075 | let is_regex = self 1076 | .app_state 1077 | .contains(AppStateFlags::IS_USING_REGEX); 1078 | 1079 | let Some(regex) = get_compiled_regex( 1080 | self.query_text.clone(), 1081 | is_case_sensitive, 1082 | is_regex, 1083 | ) 1084 | else 1085 | { 1086 | self.app_state 1087 | .insert(AppStateFlags::HAS_NO_RESULTS); 1088 | return; 1089 | }; 1090 | 1091 | // Search line by line. 1092 | for (line_num, line) in self.rfc_content.lines().enumerate() 1093 | { 1094 | let mut matches_in_line: Vec = Vec::new(); 1095 | for r#match in regex.find_iter(line) 1096 | { 1097 | // Add the range of the match. 1098 | matches_in_line.push(r#match.range()); 1099 | } 1100 | 1101 | if !matches_in_line.is_empty() 1102 | { 1103 | // Shave off excess capacity. 1104 | // Might be premature optimization, i don't know. 1105 | matches_in_line.shrink_to_fit(); 1106 | 1107 | // Add the line number and matches to the search results. 1108 | self.query_match_line_nums.push(line_num); 1109 | 1110 | // Sort the match ranges by start position to allow 1111 | // consistent iteration order. 1112 | matches_in_line 1113 | .sort_unstable_by_key(|span: &MatchSpan| span.start); 1114 | 1115 | self.query_matches 1116 | .insert(line_num, matches_in_line); 1117 | } 1118 | } 1119 | 1120 | if self.query_match_line_nums.is_empty() 1121 | { 1122 | self.app_state 1123 | .insert(AppStateFlags::HAS_NO_RESULTS); 1124 | } 1125 | // Jump to the first result starting from our location. 1126 | else 1127 | { 1128 | self.app_state 1129 | .remove(AppStateFlags::HAS_NO_RESULTS); 1130 | 1131 | self.current_query_match_index = self 1132 | .query_match_line_nums 1133 | // First position where line_num >= self.current_scroll_pos 1134 | .partition_point(|&line_num: &LineNumber| { 1135 | line_num < self.current_scroll_pos 1136 | }); 1137 | 1138 | self.jump_to_search_result(); 1139 | } 1140 | } 1141 | 1142 | /// Moves to the next search result after the current scroll position. 1143 | /// 1144 | /// If there are no search results, does nothing. 1145 | pub fn next_search_result(&mut self) 1146 | { 1147 | if !self.has_search_results() 1148 | { 1149 | return; 1150 | } 1151 | 1152 | // Find the first result after the current scroll position 1153 | if let Some(next_index) = self 1154 | .query_match_line_nums 1155 | .iter() 1156 | .position(|&line_num| line_num > self.current_scroll_pos) 1157 | { 1158 | self.current_query_match_index = next_index; 1159 | self.jump_to_search_result(); 1160 | } 1161 | } 1162 | 1163 | /// Moves to the previous search result before the current scroll position. 1164 | /// 1165 | /// If there are no search results, does nothing. 1166 | pub fn prev_search_result(&mut self) 1167 | { 1168 | if !self.has_search_results() 1169 | { 1170 | return; 1171 | } 1172 | 1173 | // Find the last result before the current scroll position 1174 | if let Some(prev_index) = self 1175 | .query_match_line_nums 1176 | .iter() 1177 | .rposition(|&line_num| line_num < self.current_scroll_pos) 1178 | { 1179 | self.current_query_match_index = prev_index; 1180 | self.jump_to_search_result(); 1181 | } 1182 | } 1183 | 1184 | /// Jumps to the current search result by scrolling to its line. 1185 | fn jump_to_search_result(&mut self) 1186 | { 1187 | if let Some(line_num) = self 1188 | .query_match_line_nums 1189 | .get(self.current_query_match_index) 1190 | { 1191 | self.current_scroll_pos = *line_num; 1192 | } 1193 | } 1194 | 1195 | /// Resets the search highlights. 1196 | pub fn reset_search_highlights(&mut self) 1197 | { 1198 | self.query_text.clear(); 1199 | self.query_match_line_nums.clear(); 1200 | self.query_matches.clear(); 1201 | self.current_query_match_index = 0; 1202 | self.app_state 1203 | .remove(AppStateFlags::HAS_NO_RESULTS); 1204 | } 1205 | } 1206 | 1207 | impl Default for App 1208 | { 1209 | fn default() -> Self 1210 | { 1211 | /// Initial capacities for common collections. 1212 | const QUERY_TEXT_INITIAL_CAPACITY: usize = 20; 1213 | const QUERY_RESULTS_INITIAL_CAPACITY: usize = 50; 1214 | 1215 | let guard = 1216 | TerminalGuard::new().expect("Failed to create terminal guard"); 1217 | 1218 | Self { 1219 | rfc_content: Box::from(""), 1220 | rfc_number: 0, 1221 | rfc_toc_panel: TocPanel::default(), 1222 | rfc_line_number: 0, 1223 | current_scroll_pos: 0, 1224 | mode: AppMode::Normal, 1225 | app_state: AppStateFlags::default(), 1226 | guard, 1227 | query_text: String::with_capacity(QUERY_TEXT_INITIAL_CAPACITY), 1228 | query_cursor_pos: 0, 1229 | query_match_line_nums: Vec::with_capacity( 1230 | QUERY_RESULTS_INITIAL_CAPACITY, 1231 | ), 1232 | current_query_match_index: 0, 1233 | query_matches: HashMap::with_capacity( 1234 | QUERY_RESULTS_INITIAL_CAPACITY, 1235 | ), 1236 | } 1237 | } 1238 | } 1239 | 1240 | /// Creates a centered rectangle inside the given area. 1241 | /// 1242 | /// # Arguments 1243 | /// 1244 | /// * `area` - The parent area 1245 | /// * `horizontal` - The horizontal constraint 1246 | /// * `vertical` - The vertical constraint 1247 | /// 1248 | /// # Returns 1249 | /// 1250 | /// A new rectangle positioned in the center of the parent 1251 | fn centered_rect( 1252 | area: Rect, 1253 | horizontal: Constraint, 1254 | vertical: Constraint, 1255 | ) -> Rect 1256 | { 1257 | let [area] = Layout::horizontal([horizontal]) 1258 | .flex(Flex::Center) 1259 | .areas(area); 1260 | let [area] = Layout::vertical([vertical]) 1261 | .flex(Flex::Center) 1262 | .areas(area); 1263 | area 1264 | } 1265 | 1266 | /// Gets a compiled regex for the given query, case sensitivity, and regex mode. 1267 | /// Uses caching to avoid recompiling the same regex multiple times. 1268 | /// 1269 | /// # Arguments 1270 | /// 1271 | /// * `query` - The search query string 1272 | /// * `is_case_sensitive` - Whether the search is case sensitive 1273 | /// * `is_regex` - Whether the query is a regex 1274 | /// 1275 | /// # Returns 1276 | /// 1277 | /// A compiled `Regex` if the query is valid, or `None` if invalid. 1278 | #[cached( 1279 | size = 20, 1280 | key = "String", 1281 | convert = r#"{ format!("{}-{}-{}", query, is_case_sensitive, is_regex) }"# 1282 | )] 1283 | fn get_compiled_regex( 1284 | query: String, 1285 | is_case_sensitive: bool, 1286 | is_regex: bool, 1287 | ) -> Option 1288 | { 1289 | let pattern = if is_regex 1290 | { 1291 | query 1292 | } 1293 | else 1294 | { 1295 | regex::escape(&query) 1296 | }; 1297 | 1298 | let case_prefix = if is_case_sensitive { "" } else { "(?i)" }; 1299 | 1300 | Regex::new(&format!("{case_prefix}{pattern}")).ok() 1301 | } 1302 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "adler2" 7 | version = "2.0.1" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 10 | 11 | [[package]] 12 | name = "ahash" 13 | version = "0.8.12" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" 16 | dependencies = [ 17 | "cfg-if", 18 | "once_cell", 19 | "version_check", 20 | "zerocopy", 21 | ] 22 | 23 | [[package]] 24 | name = "aho-corasick" 25 | version = "1.1.4" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" 28 | dependencies = [ 29 | "memchr", 30 | ] 31 | 32 | [[package]] 33 | name = "allocator-api2" 34 | version = "0.2.21" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 37 | 38 | [[package]] 39 | name = "android_system_properties" 40 | version = "0.1.5" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 43 | dependencies = [ 44 | "libc", 45 | ] 46 | 47 | [[package]] 48 | name = "anstream" 49 | version = "0.6.21" 50 | source = "registry+https://github.com/rust-lang/crates.io-index" 51 | checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" 52 | dependencies = [ 53 | "anstyle", 54 | "anstyle-parse", 55 | "anstyle-query", 56 | "anstyle-wincon", 57 | "colorchoice", 58 | "is_terminal_polyfill", 59 | "utf8parse", 60 | ] 61 | 62 | [[package]] 63 | name = "anstyle" 64 | version = "1.0.13" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" 67 | 68 | [[package]] 69 | name = "anstyle-parse" 70 | version = "0.2.7" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 73 | dependencies = [ 74 | "utf8parse", 75 | ] 76 | 77 | [[package]] 78 | name = "anstyle-query" 79 | version = "1.1.5" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" 82 | dependencies = [ 83 | "windows-sys 0.61.2", 84 | ] 85 | 86 | [[package]] 87 | name = "anstyle-wincon" 88 | version = "3.0.11" 89 | source = "registry+https://github.com/rust-lang/crates.io-index" 90 | checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" 91 | dependencies = [ 92 | "anstyle", 93 | "once_cell_polyfill", 94 | "windows-sys 0.61.2", 95 | ] 96 | 97 | [[package]] 98 | name = "anyhow" 99 | version = "1.0.100" 100 | source = "registry+https://github.com/rust-lang/crates.io-index" 101 | checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 102 | 103 | [[package]] 104 | name = "autocfg" 105 | version = "1.5.0" 106 | source = "registry+https://github.com/rust-lang/crates.io-index" 107 | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 108 | 109 | [[package]] 110 | name = "base64" 111 | version = "0.22.1" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 114 | 115 | [[package]] 116 | name = "base64ct" 117 | version = "1.8.0" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" 120 | 121 | [[package]] 122 | name = "bitflags" 123 | version = "2.10.0" 124 | source = "registry+https://github.com/rust-lang/crates.io-index" 125 | checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" 126 | 127 | [[package]] 128 | name = "bumpalo" 129 | version = "3.19.0" 130 | source = "registry+https://github.com/rust-lang/crates.io-index" 131 | checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 132 | 133 | [[package]] 134 | name = "bytes" 135 | version = "1.10.1" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 138 | 139 | [[package]] 140 | name = "cached" 141 | version = "0.56.0" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "801927ee168e17809ab8901d9f01f700cd7d8d6a6527997fee44e4b0327a253c" 144 | dependencies = [ 145 | "ahash", 146 | "cached_proc_macro", 147 | "cached_proc_macro_types", 148 | "hashbrown", 149 | "once_cell", 150 | "thiserror", 151 | "web-time", 152 | ] 153 | 154 | [[package]] 155 | name = "cached_proc_macro" 156 | version = "0.25.0" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | checksum = "9225bdcf4e4a9a4c08bf16607908eb2fbf746828d5e0b5e019726dbf6571f201" 159 | dependencies = [ 160 | "darling", 161 | "proc-macro2", 162 | "quote", 163 | "syn", 164 | ] 165 | 166 | [[package]] 167 | name = "cached_proc_macro_types" 168 | version = "0.1.1" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" 171 | 172 | [[package]] 173 | name = "cassowary" 174 | version = "0.3.0" 175 | source = "registry+https://github.com/rust-lang/crates.io-index" 176 | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 177 | 178 | [[package]] 179 | name = "castaway" 180 | version = "0.2.4" 181 | source = "registry+https://github.com/rust-lang/crates.io-index" 182 | checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" 183 | dependencies = [ 184 | "rustversion", 185 | ] 186 | 187 | [[package]] 188 | name = "cc" 189 | version = "1.2.45" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" 192 | dependencies = [ 193 | "find-msvc-tools", 194 | "shlex", 195 | ] 196 | 197 | [[package]] 198 | name = "cfg-if" 199 | version = "1.0.4" 200 | source = "registry+https://github.com/rust-lang/crates.io-index" 201 | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 202 | 203 | [[package]] 204 | name = "chrono" 205 | version = "0.4.42" 206 | source = "registry+https://github.com/rust-lang/crates.io-index" 207 | checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" 208 | dependencies = [ 209 | "iana-time-zone", 210 | "num-traits", 211 | "windows-link", 212 | ] 213 | 214 | [[package]] 215 | name = "clap" 216 | version = "4.5.53" 217 | source = "registry+https://github.com/rust-lang/crates.io-index" 218 | checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" 219 | dependencies = [ 220 | "clap_builder", 221 | ] 222 | 223 | [[package]] 224 | name = "clap_builder" 225 | version = "4.5.53" 226 | source = "registry+https://github.com/rust-lang/crates.io-index" 227 | checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" 228 | dependencies = [ 229 | "anstream", 230 | "anstyle", 231 | "clap_lex", 232 | "strsim", 233 | ] 234 | 235 | [[package]] 236 | name = "clap_lex" 237 | version = "0.7.6" 238 | source = "registry+https://github.com/rust-lang/crates.io-index" 239 | checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" 240 | 241 | [[package]] 242 | name = "colorchoice" 243 | version = "1.0.4" 244 | source = "registry+https://github.com/rust-lang/crates.io-index" 245 | checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 246 | 247 | [[package]] 248 | name = "compact_str" 249 | version = "0.8.1" 250 | source = "registry+https://github.com/rust-lang/crates.io-index" 251 | checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" 252 | dependencies = [ 253 | "castaway", 254 | "cfg-if", 255 | "itoa", 256 | "rustversion", 257 | "ryu", 258 | "static_assertions", 259 | ] 260 | 261 | [[package]] 262 | name = "core-foundation" 263 | version = "0.9.4" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 266 | dependencies = [ 267 | "core-foundation-sys", 268 | "libc", 269 | ] 270 | 271 | [[package]] 272 | name = "core-foundation-sys" 273 | version = "0.8.7" 274 | source = "registry+https://github.com/rust-lang/crates.io-index" 275 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 276 | 277 | [[package]] 278 | name = "crc32fast" 279 | version = "1.5.0" 280 | source = "registry+https://github.com/rust-lang/crates.io-index" 281 | checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" 282 | dependencies = [ 283 | "cfg-if", 284 | ] 285 | 286 | [[package]] 287 | name = "crossterm" 288 | version = "0.28.1" 289 | source = "registry+https://github.com/rust-lang/crates.io-index" 290 | checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" 291 | dependencies = [ 292 | "bitflags", 293 | "crossterm_winapi", 294 | "mio", 295 | "parking_lot", 296 | "rustix 0.38.44", 297 | "signal-hook", 298 | "signal-hook-mio", 299 | "winapi", 300 | ] 301 | 302 | [[package]] 303 | name = "crossterm_winapi" 304 | version = "0.9.1" 305 | source = "registry+https://github.com/rust-lang/crates.io-index" 306 | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 307 | dependencies = [ 308 | "winapi", 309 | ] 310 | 311 | [[package]] 312 | name = "darling" 313 | version = "0.20.11" 314 | source = "registry+https://github.com/rust-lang/crates.io-index" 315 | checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" 316 | dependencies = [ 317 | "darling_core", 318 | "darling_macro", 319 | ] 320 | 321 | [[package]] 322 | name = "darling_core" 323 | version = "0.20.11" 324 | source = "registry+https://github.com/rust-lang/crates.io-index" 325 | checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" 326 | dependencies = [ 327 | "fnv", 328 | "ident_case", 329 | "proc-macro2", 330 | "quote", 331 | "strsim", 332 | "syn", 333 | ] 334 | 335 | [[package]] 336 | name = "darling_macro" 337 | version = "0.20.11" 338 | source = "registry+https://github.com/rust-lang/crates.io-index" 339 | checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" 340 | dependencies = [ 341 | "darling_core", 342 | "quote", 343 | "syn", 344 | ] 345 | 346 | [[package]] 347 | name = "der" 348 | version = "0.7.10" 349 | source = "registry+https://github.com/rust-lang/crates.io-index" 350 | checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" 351 | dependencies = [ 352 | "pem-rfc7468", 353 | "zeroize", 354 | ] 355 | 356 | [[package]] 357 | name = "directories" 358 | version = "6.0.0" 359 | source = "registry+https://github.com/rust-lang/crates.io-index" 360 | checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" 361 | dependencies = [ 362 | "dirs-sys", 363 | ] 364 | 365 | [[package]] 366 | name = "dirs-sys" 367 | version = "0.5.0" 368 | source = "registry+https://github.com/rust-lang/crates.io-index" 369 | checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" 370 | dependencies = [ 371 | "libc", 372 | "option-ext", 373 | "redox_users", 374 | "windows-sys 0.61.2", 375 | ] 376 | 377 | [[package]] 378 | name = "either" 379 | version = "1.15.0" 380 | source = "registry+https://github.com/rust-lang/crates.io-index" 381 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 382 | 383 | [[package]] 384 | name = "env_filter" 385 | version = "0.1.4" 386 | source = "registry+https://github.com/rust-lang/crates.io-index" 387 | checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" 388 | dependencies = [ 389 | "log", 390 | "regex", 391 | ] 392 | 393 | [[package]] 394 | name = "env_logger" 395 | version = "0.11.8" 396 | source = "registry+https://github.com/rust-lang/crates.io-index" 397 | checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" 398 | dependencies = [ 399 | "anstream", 400 | "anstyle", 401 | "env_filter", 402 | "jiff", 403 | "log", 404 | ] 405 | 406 | [[package]] 407 | name = "equivalent" 408 | version = "1.0.2" 409 | source = "registry+https://github.com/rust-lang/crates.io-index" 410 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 411 | 412 | [[package]] 413 | name = "errno" 414 | version = "0.3.14" 415 | source = "registry+https://github.com/rust-lang/crates.io-index" 416 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 417 | dependencies = [ 418 | "libc", 419 | "windows-sys 0.61.2", 420 | ] 421 | 422 | [[package]] 423 | name = "fastrand" 424 | version = "2.3.0" 425 | source = "registry+https://github.com/rust-lang/crates.io-index" 426 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 427 | 428 | [[package]] 429 | name = "file-rotate" 430 | version = "0.8.0" 431 | source = "registry+https://github.com/rust-lang/crates.io-index" 432 | checksum = "6e8e2fa049328a1f3295991407a88585805d126dfaadf74b9fe8c194c730aafc" 433 | dependencies = [ 434 | "chrono", 435 | "flate2", 436 | ] 437 | 438 | [[package]] 439 | name = "find-msvc-tools" 440 | version = "0.1.4" 441 | source = "registry+https://github.com/rust-lang/crates.io-index" 442 | checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" 443 | 444 | [[package]] 445 | name = "flate2" 446 | version = "1.1.5" 447 | source = "registry+https://github.com/rust-lang/crates.io-index" 448 | checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" 449 | dependencies = [ 450 | "crc32fast", 451 | "miniz_oxide", 452 | ] 453 | 454 | [[package]] 455 | name = "fnv" 456 | version = "1.0.7" 457 | source = "registry+https://github.com/rust-lang/crates.io-index" 458 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 459 | 460 | [[package]] 461 | name = "foldhash" 462 | version = "0.1.5" 463 | source = "registry+https://github.com/rust-lang/crates.io-index" 464 | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 465 | 466 | [[package]] 467 | name = "foreign-types" 468 | version = "0.3.2" 469 | source = "registry+https://github.com/rust-lang/crates.io-index" 470 | checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" 471 | dependencies = [ 472 | "foreign-types-shared", 473 | ] 474 | 475 | [[package]] 476 | name = "foreign-types-shared" 477 | version = "0.1.1" 478 | source = "registry+https://github.com/rust-lang/crates.io-index" 479 | checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" 480 | 481 | [[package]] 482 | name = "getrandom" 483 | version = "0.2.16" 484 | source = "registry+https://github.com/rust-lang/crates.io-index" 485 | checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 486 | dependencies = [ 487 | "cfg-if", 488 | "libc", 489 | "wasi", 490 | ] 491 | 492 | [[package]] 493 | name = "getrandom" 494 | version = "0.3.4" 495 | source = "registry+https://github.com/rust-lang/crates.io-index" 496 | checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 497 | dependencies = [ 498 | "cfg-if", 499 | "libc", 500 | "r-efi", 501 | "wasip2", 502 | ] 503 | 504 | [[package]] 505 | name = "hashbrown" 506 | version = "0.15.5" 507 | source = "registry+https://github.com/rust-lang/crates.io-index" 508 | checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 509 | dependencies = [ 510 | "allocator-api2", 511 | "equivalent", 512 | "foldhash", 513 | ] 514 | 515 | [[package]] 516 | name = "heck" 517 | version = "0.5.0" 518 | source = "registry+https://github.com/rust-lang/crates.io-index" 519 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 520 | 521 | [[package]] 522 | name = "http" 523 | version = "1.3.1" 524 | source = "registry+https://github.com/rust-lang/crates.io-index" 525 | checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" 526 | dependencies = [ 527 | "bytes", 528 | "fnv", 529 | "itoa", 530 | ] 531 | 532 | [[package]] 533 | name = "httparse" 534 | version = "1.10.1" 535 | source = "registry+https://github.com/rust-lang/crates.io-index" 536 | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 537 | 538 | [[package]] 539 | name = "iana-time-zone" 540 | version = "0.1.64" 541 | source = "registry+https://github.com/rust-lang/crates.io-index" 542 | checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" 543 | dependencies = [ 544 | "android_system_properties", 545 | "core-foundation-sys", 546 | "iana-time-zone-haiku", 547 | "js-sys", 548 | "log", 549 | "wasm-bindgen", 550 | "windows-core", 551 | ] 552 | 553 | [[package]] 554 | name = "iana-time-zone-haiku" 555 | version = "0.1.2" 556 | source = "registry+https://github.com/rust-lang/crates.io-index" 557 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 558 | dependencies = [ 559 | "cc", 560 | ] 561 | 562 | [[package]] 563 | name = "ident_case" 564 | version = "1.0.1" 565 | source = "registry+https://github.com/rust-lang/crates.io-index" 566 | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 567 | 568 | [[package]] 569 | name = "indoc" 570 | version = "2.0.7" 571 | source = "registry+https://github.com/rust-lang/crates.io-index" 572 | checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" 573 | dependencies = [ 574 | "rustversion", 575 | ] 576 | 577 | [[package]] 578 | name = "instability" 579 | version = "0.3.9" 580 | source = "registry+https://github.com/rust-lang/crates.io-index" 581 | checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" 582 | dependencies = [ 583 | "darling", 584 | "indoc", 585 | "proc-macro2", 586 | "quote", 587 | "syn", 588 | ] 589 | 590 | [[package]] 591 | name = "is_terminal_polyfill" 592 | version = "1.70.2" 593 | source = "registry+https://github.com/rust-lang/crates.io-index" 594 | checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 595 | 596 | [[package]] 597 | name = "itertools" 598 | version = "0.13.0" 599 | source = "registry+https://github.com/rust-lang/crates.io-index" 600 | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 601 | dependencies = [ 602 | "either", 603 | ] 604 | 605 | [[package]] 606 | name = "itoa" 607 | version = "1.0.15" 608 | source = "registry+https://github.com/rust-lang/crates.io-index" 609 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 610 | 611 | [[package]] 612 | name = "jiff" 613 | version = "0.2.16" 614 | source = "registry+https://github.com/rust-lang/crates.io-index" 615 | checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" 616 | dependencies = [ 617 | "jiff-static", 618 | "log", 619 | "portable-atomic", 620 | "portable-atomic-util", 621 | "serde_core", 622 | ] 623 | 624 | [[package]] 625 | name = "jiff-static" 626 | version = "0.2.16" 627 | source = "registry+https://github.com/rust-lang/crates.io-index" 628 | checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" 629 | dependencies = [ 630 | "proc-macro2", 631 | "quote", 632 | "syn", 633 | ] 634 | 635 | [[package]] 636 | name = "js-sys" 637 | version = "0.3.82" 638 | source = "registry+https://github.com/rust-lang/crates.io-index" 639 | checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" 640 | dependencies = [ 641 | "once_cell", 642 | "wasm-bindgen", 643 | ] 644 | 645 | [[package]] 646 | name = "libc" 647 | version = "0.2.177" 648 | source = "registry+https://github.com/rust-lang/crates.io-index" 649 | checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" 650 | 651 | [[package]] 652 | name = "libredox" 653 | version = "0.1.10" 654 | source = "registry+https://github.com/rust-lang/crates.io-index" 655 | checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" 656 | dependencies = [ 657 | "bitflags", 658 | "libc", 659 | ] 660 | 661 | [[package]] 662 | name = "linux-raw-sys" 663 | version = "0.4.15" 664 | source = "registry+https://github.com/rust-lang/crates.io-index" 665 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 666 | 667 | [[package]] 668 | name = "linux-raw-sys" 669 | version = "0.11.0" 670 | source = "registry+https://github.com/rust-lang/crates.io-index" 671 | checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" 672 | 673 | [[package]] 674 | name = "lock_api" 675 | version = "0.4.14" 676 | source = "registry+https://github.com/rust-lang/crates.io-index" 677 | checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 678 | dependencies = [ 679 | "scopeguard", 680 | ] 681 | 682 | [[package]] 683 | name = "log" 684 | version = "0.4.29" 685 | source = "registry+https://github.com/rust-lang/crates.io-index" 686 | checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 687 | 688 | [[package]] 689 | name = "lru" 690 | version = "0.12.5" 691 | source = "registry+https://github.com/rust-lang/crates.io-index" 692 | checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 693 | dependencies = [ 694 | "hashbrown", 695 | ] 696 | 697 | [[package]] 698 | name = "memchr" 699 | version = "2.7.6" 700 | source = "registry+https://github.com/rust-lang/crates.io-index" 701 | checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 702 | 703 | [[package]] 704 | name = "miniz_oxide" 705 | version = "0.8.9" 706 | source = "registry+https://github.com/rust-lang/crates.io-index" 707 | checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" 708 | dependencies = [ 709 | "adler2", 710 | "simd-adler32", 711 | ] 712 | 713 | [[package]] 714 | name = "mio" 715 | version = "1.1.0" 716 | source = "registry+https://github.com/rust-lang/crates.io-index" 717 | checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" 718 | dependencies = [ 719 | "libc", 720 | "log", 721 | "wasi", 722 | "windows-sys 0.61.2", 723 | ] 724 | 725 | [[package]] 726 | name = "native-tls" 727 | version = "0.2.14" 728 | source = "registry+https://github.com/rust-lang/crates.io-index" 729 | checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" 730 | dependencies = [ 731 | "libc", 732 | "log", 733 | "openssl", 734 | "openssl-probe", 735 | "openssl-sys", 736 | "schannel", 737 | "security-framework", 738 | "security-framework-sys", 739 | "tempfile", 740 | ] 741 | 742 | [[package]] 743 | name = "num-traits" 744 | version = "0.2.19" 745 | source = "registry+https://github.com/rust-lang/crates.io-index" 746 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 747 | dependencies = [ 748 | "autocfg", 749 | ] 750 | 751 | [[package]] 752 | name = "once_cell" 753 | version = "1.21.3" 754 | source = "registry+https://github.com/rust-lang/crates.io-index" 755 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 756 | 757 | [[package]] 758 | name = "once_cell_polyfill" 759 | version = "1.70.2" 760 | source = "registry+https://github.com/rust-lang/crates.io-index" 761 | checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 762 | 763 | [[package]] 764 | name = "openssl" 765 | version = "0.10.75" 766 | source = "registry+https://github.com/rust-lang/crates.io-index" 767 | checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" 768 | dependencies = [ 769 | "bitflags", 770 | "cfg-if", 771 | "foreign-types", 772 | "libc", 773 | "once_cell", 774 | "openssl-macros", 775 | "openssl-sys", 776 | ] 777 | 778 | [[package]] 779 | name = "openssl-macros" 780 | version = "0.1.1" 781 | source = "registry+https://github.com/rust-lang/crates.io-index" 782 | checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" 783 | dependencies = [ 784 | "proc-macro2", 785 | "quote", 786 | "syn", 787 | ] 788 | 789 | [[package]] 790 | name = "openssl-probe" 791 | version = "0.1.6" 792 | source = "registry+https://github.com/rust-lang/crates.io-index" 793 | checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" 794 | 795 | [[package]] 796 | name = "openssl-sys" 797 | version = "0.9.111" 798 | source = "registry+https://github.com/rust-lang/crates.io-index" 799 | checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" 800 | dependencies = [ 801 | "cc", 802 | "libc", 803 | "pkg-config", 804 | "vcpkg", 805 | ] 806 | 807 | [[package]] 808 | name = "option-ext" 809 | version = "0.2.0" 810 | source = "registry+https://github.com/rust-lang/crates.io-index" 811 | checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 812 | 813 | [[package]] 814 | name = "parking_lot" 815 | version = "0.12.5" 816 | source = "registry+https://github.com/rust-lang/crates.io-index" 817 | checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" 818 | dependencies = [ 819 | "lock_api", 820 | "parking_lot_core", 821 | ] 822 | 823 | [[package]] 824 | name = "parking_lot_core" 825 | version = "0.9.12" 826 | source = "registry+https://github.com/rust-lang/crates.io-index" 827 | checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" 828 | dependencies = [ 829 | "cfg-if", 830 | "libc", 831 | "redox_syscall", 832 | "smallvec", 833 | "windows-link", 834 | ] 835 | 836 | [[package]] 837 | name = "paste" 838 | version = "1.0.15" 839 | source = "registry+https://github.com/rust-lang/crates.io-index" 840 | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 841 | 842 | [[package]] 843 | name = "pem-rfc7468" 844 | version = "0.7.0" 845 | source = "registry+https://github.com/rust-lang/crates.io-index" 846 | checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" 847 | dependencies = [ 848 | "base64ct", 849 | ] 850 | 851 | [[package]] 852 | name = "percent-encoding" 853 | version = "2.3.2" 854 | source = "registry+https://github.com/rust-lang/crates.io-index" 855 | checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 856 | 857 | [[package]] 858 | name = "pkg-config" 859 | version = "0.3.32" 860 | source = "registry+https://github.com/rust-lang/crates.io-index" 861 | checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 862 | 863 | [[package]] 864 | name = "portable-atomic" 865 | version = "1.11.1" 866 | source = "registry+https://github.com/rust-lang/crates.io-index" 867 | checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" 868 | 869 | [[package]] 870 | name = "portable-atomic-util" 871 | version = "0.2.4" 872 | source = "registry+https://github.com/rust-lang/crates.io-index" 873 | checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" 874 | dependencies = [ 875 | "portable-atomic", 876 | ] 877 | 878 | [[package]] 879 | name = "proc-macro2" 880 | version = "1.0.103" 881 | source = "registry+https://github.com/rust-lang/crates.io-index" 882 | checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" 883 | dependencies = [ 884 | "unicode-ident", 885 | ] 886 | 887 | [[package]] 888 | name = "quote" 889 | version = "1.0.42" 890 | source = "registry+https://github.com/rust-lang/crates.io-index" 891 | checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" 892 | dependencies = [ 893 | "proc-macro2", 894 | ] 895 | 896 | [[package]] 897 | name = "r-efi" 898 | version = "5.3.0" 899 | source = "registry+https://github.com/rust-lang/crates.io-index" 900 | checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 901 | 902 | [[package]] 903 | name = "ratatui" 904 | version = "0.29.0" 905 | source = "registry+https://github.com/rust-lang/crates.io-index" 906 | checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" 907 | dependencies = [ 908 | "bitflags", 909 | "cassowary", 910 | "compact_str", 911 | "crossterm", 912 | "indoc", 913 | "instability", 914 | "itertools", 915 | "lru", 916 | "paste", 917 | "strum", 918 | "unicode-segmentation", 919 | "unicode-truncate", 920 | "unicode-width 0.2.0", 921 | ] 922 | 923 | [[package]] 924 | name = "redox_syscall" 925 | version = "0.5.18" 926 | source = "registry+https://github.com/rust-lang/crates.io-index" 927 | checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 928 | dependencies = [ 929 | "bitflags", 930 | ] 931 | 932 | [[package]] 933 | name = "redox_users" 934 | version = "0.5.2" 935 | source = "registry+https://github.com/rust-lang/crates.io-index" 936 | checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" 937 | dependencies = [ 938 | "getrandom 0.2.16", 939 | "libredox", 940 | "thiserror", 941 | ] 942 | 943 | [[package]] 944 | name = "regex" 945 | version = "1.12.2" 946 | source = "registry+https://github.com/rust-lang/crates.io-index" 947 | checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" 948 | dependencies = [ 949 | "aho-corasick", 950 | "memchr", 951 | "regex-automata", 952 | "regex-syntax", 953 | ] 954 | 955 | [[package]] 956 | name = "regex-automata" 957 | version = "0.4.13" 958 | source = "registry+https://github.com/rust-lang/crates.io-index" 959 | checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" 960 | dependencies = [ 961 | "aho-corasick", 962 | "memchr", 963 | "regex-syntax", 964 | ] 965 | 966 | [[package]] 967 | name = "regex-syntax" 968 | version = "0.8.8" 969 | source = "registry+https://github.com/rust-lang/crates.io-index" 970 | checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" 971 | 972 | [[package]] 973 | name = "rfc_reader" 974 | version = "0.11.2" 975 | dependencies = [ 976 | "anyhow", 977 | "bitflags", 978 | "cached", 979 | "clap", 980 | "crossterm", 981 | "directories", 982 | "env_logger", 983 | "file-rotate", 984 | "log", 985 | "ratatui", 986 | "regex", 987 | "tempfile", 988 | "textwrap", 989 | "ureq", 990 | ] 991 | 992 | [[package]] 993 | name = "rustix" 994 | version = "0.38.44" 995 | source = "registry+https://github.com/rust-lang/crates.io-index" 996 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 997 | dependencies = [ 998 | "bitflags", 999 | "errno", 1000 | "libc", 1001 | "linux-raw-sys 0.4.15", 1002 | "windows-sys 0.59.0", 1003 | ] 1004 | 1005 | [[package]] 1006 | name = "rustix" 1007 | version = "1.1.2" 1008 | source = "registry+https://github.com/rust-lang/crates.io-index" 1009 | checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" 1010 | dependencies = [ 1011 | "bitflags", 1012 | "errno", 1013 | "libc", 1014 | "linux-raw-sys 0.11.0", 1015 | "windows-sys 0.61.2", 1016 | ] 1017 | 1018 | [[package]] 1019 | name = "rustls-pki-types" 1020 | version = "1.13.0" 1021 | source = "registry+https://github.com/rust-lang/crates.io-index" 1022 | checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" 1023 | dependencies = [ 1024 | "zeroize", 1025 | ] 1026 | 1027 | [[package]] 1028 | name = "rustversion" 1029 | version = "1.0.22" 1030 | source = "registry+https://github.com/rust-lang/crates.io-index" 1031 | checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 1032 | 1033 | [[package]] 1034 | name = "ryu" 1035 | version = "1.0.20" 1036 | source = "registry+https://github.com/rust-lang/crates.io-index" 1037 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 1038 | 1039 | [[package]] 1040 | name = "schannel" 1041 | version = "0.1.28" 1042 | source = "registry+https://github.com/rust-lang/crates.io-index" 1043 | checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" 1044 | dependencies = [ 1045 | "windows-sys 0.61.2", 1046 | ] 1047 | 1048 | [[package]] 1049 | name = "scopeguard" 1050 | version = "1.2.0" 1051 | source = "registry+https://github.com/rust-lang/crates.io-index" 1052 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1053 | 1054 | [[package]] 1055 | name = "security-framework" 1056 | version = "2.11.1" 1057 | source = "registry+https://github.com/rust-lang/crates.io-index" 1058 | checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" 1059 | dependencies = [ 1060 | "bitflags", 1061 | "core-foundation", 1062 | "core-foundation-sys", 1063 | "libc", 1064 | "security-framework-sys", 1065 | ] 1066 | 1067 | [[package]] 1068 | name = "security-framework-sys" 1069 | version = "2.15.0" 1070 | source = "registry+https://github.com/rust-lang/crates.io-index" 1071 | checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" 1072 | dependencies = [ 1073 | "core-foundation-sys", 1074 | "libc", 1075 | ] 1076 | 1077 | [[package]] 1078 | name = "serde_core" 1079 | version = "1.0.228" 1080 | source = "registry+https://github.com/rust-lang/crates.io-index" 1081 | checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 1082 | dependencies = [ 1083 | "serde_derive", 1084 | ] 1085 | 1086 | [[package]] 1087 | name = "serde_derive" 1088 | version = "1.0.228" 1089 | source = "registry+https://github.com/rust-lang/crates.io-index" 1090 | checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 1091 | dependencies = [ 1092 | "proc-macro2", 1093 | "quote", 1094 | "syn", 1095 | ] 1096 | 1097 | [[package]] 1098 | name = "shlex" 1099 | version = "1.3.0" 1100 | source = "registry+https://github.com/rust-lang/crates.io-index" 1101 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 1102 | 1103 | [[package]] 1104 | name = "signal-hook" 1105 | version = "0.3.18" 1106 | source = "registry+https://github.com/rust-lang/crates.io-index" 1107 | checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" 1108 | dependencies = [ 1109 | "libc", 1110 | "signal-hook-registry", 1111 | ] 1112 | 1113 | [[package]] 1114 | name = "signal-hook-mio" 1115 | version = "0.2.5" 1116 | source = "registry+https://github.com/rust-lang/crates.io-index" 1117 | checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" 1118 | dependencies = [ 1119 | "libc", 1120 | "mio", 1121 | "signal-hook", 1122 | ] 1123 | 1124 | [[package]] 1125 | name = "signal-hook-registry" 1126 | version = "1.4.6" 1127 | source = "registry+https://github.com/rust-lang/crates.io-index" 1128 | checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" 1129 | dependencies = [ 1130 | "libc", 1131 | ] 1132 | 1133 | [[package]] 1134 | name = "simd-adler32" 1135 | version = "0.3.7" 1136 | source = "registry+https://github.com/rust-lang/crates.io-index" 1137 | checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" 1138 | 1139 | [[package]] 1140 | name = "smallvec" 1141 | version = "1.15.1" 1142 | source = "registry+https://github.com/rust-lang/crates.io-index" 1143 | checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 1144 | 1145 | [[package]] 1146 | name = "smawk" 1147 | version = "0.3.2" 1148 | source = "registry+https://github.com/rust-lang/crates.io-index" 1149 | checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" 1150 | 1151 | [[package]] 1152 | name = "static_assertions" 1153 | version = "1.1.0" 1154 | source = "registry+https://github.com/rust-lang/crates.io-index" 1155 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 1156 | 1157 | [[package]] 1158 | name = "strsim" 1159 | version = "0.11.1" 1160 | source = "registry+https://github.com/rust-lang/crates.io-index" 1161 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 1162 | 1163 | [[package]] 1164 | name = "strum" 1165 | version = "0.26.3" 1166 | source = "registry+https://github.com/rust-lang/crates.io-index" 1167 | checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" 1168 | dependencies = [ 1169 | "strum_macros", 1170 | ] 1171 | 1172 | [[package]] 1173 | name = "strum_macros" 1174 | version = "0.26.4" 1175 | source = "registry+https://github.com/rust-lang/crates.io-index" 1176 | checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" 1177 | dependencies = [ 1178 | "heck", 1179 | "proc-macro2", 1180 | "quote", 1181 | "rustversion", 1182 | "syn", 1183 | ] 1184 | 1185 | [[package]] 1186 | name = "syn" 1187 | version = "2.0.110" 1188 | source = "registry+https://github.com/rust-lang/crates.io-index" 1189 | checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" 1190 | dependencies = [ 1191 | "proc-macro2", 1192 | "quote", 1193 | "unicode-ident", 1194 | ] 1195 | 1196 | [[package]] 1197 | name = "tempfile" 1198 | version = "3.23.0" 1199 | source = "registry+https://github.com/rust-lang/crates.io-index" 1200 | checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" 1201 | dependencies = [ 1202 | "fastrand", 1203 | "getrandom 0.3.4", 1204 | "once_cell", 1205 | "rustix 1.1.2", 1206 | "windows-sys 0.61.2", 1207 | ] 1208 | 1209 | [[package]] 1210 | name = "textwrap" 1211 | version = "0.16.2" 1212 | source = "registry+https://github.com/rust-lang/crates.io-index" 1213 | checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" 1214 | dependencies = [ 1215 | "smawk", 1216 | "unicode-linebreak", 1217 | "unicode-width 0.2.0", 1218 | ] 1219 | 1220 | [[package]] 1221 | name = "thiserror" 1222 | version = "2.0.17" 1223 | source = "registry+https://github.com/rust-lang/crates.io-index" 1224 | checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" 1225 | dependencies = [ 1226 | "thiserror-impl", 1227 | ] 1228 | 1229 | [[package]] 1230 | name = "thiserror-impl" 1231 | version = "2.0.17" 1232 | source = "registry+https://github.com/rust-lang/crates.io-index" 1233 | checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" 1234 | dependencies = [ 1235 | "proc-macro2", 1236 | "quote", 1237 | "syn", 1238 | ] 1239 | 1240 | [[package]] 1241 | name = "unicode-ident" 1242 | version = "1.0.22" 1243 | source = "registry+https://github.com/rust-lang/crates.io-index" 1244 | checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" 1245 | 1246 | [[package]] 1247 | name = "unicode-linebreak" 1248 | version = "0.1.5" 1249 | source = "registry+https://github.com/rust-lang/crates.io-index" 1250 | checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" 1251 | 1252 | [[package]] 1253 | name = "unicode-segmentation" 1254 | version = "1.12.0" 1255 | source = "registry+https://github.com/rust-lang/crates.io-index" 1256 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 1257 | 1258 | [[package]] 1259 | name = "unicode-truncate" 1260 | version = "1.1.0" 1261 | source = "registry+https://github.com/rust-lang/crates.io-index" 1262 | checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" 1263 | dependencies = [ 1264 | "itertools", 1265 | "unicode-segmentation", 1266 | "unicode-width 0.1.14", 1267 | ] 1268 | 1269 | [[package]] 1270 | name = "unicode-width" 1271 | version = "0.1.14" 1272 | source = "registry+https://github.com/rust-lang/crates.io-index" 1273 | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 1274 | 1275 | [[package]] 1276 | name = "unicode-width" 1277 | version = "0.2.0" 1278 | source = "registry+https://github.com/rust-lang/crates.io-index" 1279 | checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" 1280 | 1281 | [[package]] 1282 | name = "ureq" 1283 | version = "3.1.4" 1284 | source = "registry+https://github.com/rust-lang/crates.io-index" 1285 | checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" 1286 | dependencies = [ 1287 | "base64", 1288 | "der", 1289 | "log", 1290 | "native-tls", 1291 | "percent-encoding", 1292 | "rustls-pki-types", 1293 | "ureq-proto", 1294 | "utf-8", 1295 | "webpki-root-certs", 1296 | ] 1297 | 1298 | [[package]] 1299 | name = "ureq-proto" 1300 | version = "0.5.2" 1301 | source = "registry+https://github.com/rust-lang/crates.io-index" 1302 | checksum = "60b4531c118335662134346048ddb0e54cc86bd7e81866757873055f0e38f5d2" 1303 | dependencies = [ 1304 | "base64", 1305 | "http", 1306 | "httparse", 1307 | "log", 1308 | ] 1309 | 1310 | [[package]] 1311 | name = "utf-8" 1312 | version = "0.7.6" 1313 | source = "registry+https://github.com/rust-lang/crates.io-index" 1314 | checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 1315 | 1316 | [[package]] 1317 | name = "utf8parse" 1318 | version = "0.2.2" 1319 | source = "registry+https://github.com/rust-lang/crates.io-index" 1320 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 1321 | 1322 | [[package]] 1323 | name = "vcpkg" 1324 | version = "0.2.15" 1325 | source = "registry+https://github.com/rust-lang/crates.io-index" 1326 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 1327 | 1328 | [[package]] 1329 | name = "version_check" 1330 | version = "0.9.5" 1331 | source = "registry+https://github.com/rust-lang/crates.io-index" 1332 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 1333 | 1334 | [[package]] 1335 | name = "wasi" 1336 | version = "0.11.1+wasi-snapshot-preview1" 1337 | source = "registry+https://github.com/rust-lang/crates.io-index" 1338 | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 1339 | 1340 | [[package]] 1341 | name = "wasip2" 1342 | version = "1.0.1+wasi-0.2.4" 1343 | source = "registry+https://github.com/rust-lang/crates.io-index" 1344 | checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" 1345 | dependencies = [ 1346 | "wit-bindgen", 1347 | ] 1348 | 1349 | [[package]] 1350 | name = "wasm-bindgen" 1351 | version = "0.2.105" 1352 | source = "registry+https://github.com/rust-lang/crates.io-index" 1353 | checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" 1354 | dependencies = [ 1355 | "cfg-if", 1356 | "once_cell", 1357 | "rustversion", 1358 | "wasm-bindgen-macro", 1359 | "wasm-bindgen-shared", 1360 | ] 1361 | 1362 | [[package]] 1363 | name = "wasm-bindgen-macro" 1364 | version = "0.2.105" 1365 | source = "registry+https://github.com/rust-lang/crates.io-index" 1366 | checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" 1367 | dependencies = [ 1368 | "quote", 1369 | "wasm-bindgen-macro-support", 1370 | ] 1371 | 1372 | [[package]] 1373 | name = "wasm-bindgen-macro-support" 1374 | version = "0.2.105" 1375 | source = "registry+https://github.com/rust-lang/crates.io-index" 1376 | checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" 1377 | dependencies = [ 1378 | "bumpalo", 1379 | "proc-macro2", 1380 | "quote", 1381 | "syn", 1382 | "wasm-bindgen-shared", 1383 | ] 1384 | 1385 | [[package]] 1386 | name = "wasm-bindgen-shared" 1387 | version = "0.2.105" 1388 | source = "registry+https://github.com/rust-lang/crates.io-index" 1389 | checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" 1390 | dependencies = [ 1391 | "unicode-ident", 1392 | ] 1393 | 1394 | [[package]] 1395 | name = "web-time" 1396 | version = "1.1.0" 1397 | source = "registry+https://github.com/rust-lang/crates.io-index" 1398 | checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" 1399 | dependencies = [ 1400 | "js-sys", 1401 | "wasm-bindgen", 1402 | ] 1403 | 1404 | [[package]] 1405 | name = "webpki-root-certs" 1406 | version = "1.0.4" 1407 | source = "registry+https://github.com/rust-lang/crates.io-index" 1408 | checksum = "ee3e3b5f5e80bc89f30ce8d0343bf4e5f12341c51f3e26cbeecbc7c85443e85b" 1409 | dependencies = [ 1410 | "rustls-pki-types", 1411 | ] 1412 | 1413 | [[package]] 1414 | name = "winapi" 1415 | version = "0.3.9" 1416 | source = "registry+https://github.com/rust-lang/crates.io-index" 1417 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1418 | dependencies = [ 1419 | "winapi-i686-pc-windows-gnu", 1420 | "winapi-x86_64-pc-windows-gnu", 1421 | ] 1422 | 1423 | [[package]] 1424 | name = "winapi-i686-pc-windows-gnu" 1425 | version = "0.4.0" 1426 | source = "registry+https://github.com/rust-lang/crates.io-index" 1427 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1428 | 1429 | [[package]] 1430 | name = "winapi-x86_64-pc-windows-gnu" 1431 | version = "0.4.0" 1432 | source = "registry+https://github.com/rust-lang/crates.io-index" 1433 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1434 | 1435 | [[package]] 1436 | name = "windows-core" 1437 | version = "0.62.2" 1438 | source = "registry+https://github.com/rust-lang/crates.io-index" 1439 | checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" 1440 | dependencies = [ 1441 | "windows-implement", 1442 | "windows-interface", 1443 | "windows-link", 1444 | "windows-result", 1445 | "windows-strings", 1446 | ] 1447 | 1448 | [[package]] 1449 | name = "windows-implement" 1450 | version = "0.60.2" 1451 | source = "registry+https://github.com/rust-lang/crates.io-index" 1452 | checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" 1453 | dependencies = [ 1454 | "proc-macro2", 1455 | "quote", 1456 | "syn", 1457 | ] 1458 | 1459 | [[package]] 1460 | name = "windows-interface" 1461 | version = "0.59.3" 1462 | source = "registry+https://github.com/rust-lang/crates.io-index" 1463 | checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" 1464 | dependencies = [ 1465 | "proc-macro2", 1466 | "quote", 1467 | "syn", 1468 | ] 1469 | 1470 | [[package]] 1471 | name = "windows-link" 1472 | version = "0.2.1" 1473 | source = "registry+https://github.com/rust-lang/crates.io-index" 1474 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 1475 | 1476 | [[package]] 1477 | name = "windows-result" 1478 | version = "0.4.1" 1479 | source = "registry+https://github.com/rust-lang/crates.io-index" 1480 | checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" 1481 | dependencies = [ 1482 | "windows-link", 1483 | ] 1484 | 1485 | [[package]] 1486 | name = "windows-strings" 1487 | version = "0.5.1" 1488 | source = "registry+https://github.com/rust-lang/crates.io-index" 1489 | checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" 1490 | dependencies = [ 1491 | "windows-link", 1492 | ] 1493 | 1494 | [[package]] 1495 | name = "windows-sys" 1496 | version = "0.59.0" 1497 | source = "registry+https://github.com/rust-lang/crates.io-index" 1498 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 1499 | dependencies = [ 1500 | "windows-targets", 1501 | ] 1502 | 1503 | [[package]] 1504 | name = "windows-sys" 1505 | version = "0.61.2" 1506 | source = "registry+https://github.com/rust-lang/crates.io-index" 1507 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 1508 | dependencies = [ 1509 | "windows-link", 1510 | ] 1511 | 1512 | [[package]] 1513 | name = "windows-targets" 1514 | version = "0.52.6" 1515 | source = "registry+https://github.com/rust-lang/crates.io-index" 1516 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1517 | dependencies = [ 1518 | "windows_aarch64_gnullvm", 1519 | "windows_aarch64_msvc", 1520 | "windows_i686_gnu", 1521 | "windows_i686_gnullvm", 1522 | "windows_i686_msvc", 1523 | "windows_x86_64_gnu", 1524 | "windows_x86_64_gnullvm", 1525 | "windows_x86_64_msvc", 1526 | ] 1527 | 1528 | [[package]] 1529 | name = "windows_aarch64_gnullvm" 1530 | version = "0.52.6" 1531 | source = "registry+https://github.com/rust-lang/crates.io-index" 1532 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1533 | 1534 | [[package]] 1535 | name = "windows_aarch64_msvc" 1536 | version = "0.52.6" 1537 | source = "registry+https://github.com/rust-lang/crates.io-index" 1538 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1539 | 1540 | [[package]] 1541 | name = "windows_i686_gnu" 1542 | version = "0.52.6" 1543 | source = "registry+https://github.com/rust-lang/crates.io-index" 1544 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1545 | 1546 | [[package]] 1547 | name = "windows_i686_gnullvm" 1548 | version = "0.52.6" 1549 | source = "registry+https://github.com/rust-lang/crates.io-index" 1550 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1551 | 1552 | [[package]] 1553 | name = "windows_i686_msvc" 1554 | version = "0.52.6" 1555 | source = "registry+https://github.com/rust-lang/crates.io-index" 1556 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1557 | 1558 | [[package]] 1559 | name = "windows_x86_64_gnu" 1560 | version = "0.52.6" 1561 | source = "registry+https://github.com/rust-lang/crates.io-index" 1562 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1563 | 1564 | [[package]] 1565 | name = "windows_x86_64_gnullvm" 1566 | version = "0.52.6" 1567 | source = "registry+https://github.com/rust-lang/crates.io-index" 1568 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1569 | 1570 | [[package]] 1571 | name = "windows_x86_64_msvc" 1572 | version = "0.52.6" 1573 | source = "registry+https://github.com/rust-lang/crates.io-index" 1574 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1575 | 1576 | [[package]] 1577 | name = "wit-bindgen" 1578 | version = "0.46.0" 1579 | source = "registry+https://github.com/rust-lang/crates.io-index" 1580 | checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" 1581 | 1582 | [[package]] 1583 | name = "zerocopy" 1584 | version = "0.8.27" 1585 | source = "registry+https://github.com/rust-lang/crates.io-index" 1586 | checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" 1587 | dependencies = [ 1588 | "zerocopy-derive", 1589 | ] 1590 | 1591 | [[package]] 1592 | name = "zerocopy-derive" 1593 | version = "0.8.27" 1594 | source = "registry+https://github.com/rust-lang/crates.io-index" 1595 | checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" 1596 | dependencies = [ 1597 | "proc-macro2", 1598 | "quote", 1599 | "syn", 1600 | ] 1601 | 1602 | [[package]] 1603 | name = "zeroize" 1604 | version = "1.8.2" 1605 | source = "registry+https://github.com/rust-lang/crates.io-index" 1606 | checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 1607 | --------------------------------------------------------------------------------