diff --git a/crates/toolbox/Cargo.toml b/crates/toolbox/Cargo.toml new file mode 100644 index 0000000..fae188c --- /dev/null +++ b/crates/toolbox/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "toolbox" +version = { workspace = true } +rust-version = { workspace = true } +edition = { workspace = true } + +[lints] +workspace = true + +[dev-dependencies] +tokio = { workspace = true } + +[dependencies] +serde = { workspace = true } +tracing = { workspace = true } +num = { workspace = true } + +clap = { workspace = true, optional = true } +anstyle = { workspace = true, optional = true } +tracing-subscriber = { workspace = true, optional = true } +anyhow = { workspace = true, optional = true } + +tracing-loki = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } +url = { workspace = true, optional = true } +base64 = { workspace = true, optional = true } + +[features] +default = [] +cli = ["dep:clap", "dep:anstyle", "dep:tracing-subscriber", "dep:anyhow"] +loki = ["cli", "dep:tracing-loki", "dep:base64", "dep:tokio", "dep:url"] diff --git a/crates/toolbox/src/cli.rs b/crates/toolbox/src/cli.rs new file mode 100644 index 0000000..0daea2a --- /dev/null +++ b/crates/toolbox/src/cli.rs @@ -0,0 +1,37 @@ +use anstyle::{AnsiColor, Color, Style}; + +pub fn clap_styles() -> clap::builder::Styles { + clap::builder::Styles::styled() + .usage( + Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::Blue))), + ) + .header( + Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::Blue))), + ) + .literal( + Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::BrightBlack))), + ) + .invalid( + Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::Red))), + ) + .error( + Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::Red))), + ) + .valid( + Style::new() + .bold() + .underline() + .fg_color(Some(Color::Ansi(AnsiColor::Green))), + ) + .placeholder(Style::new().fg_color(Some(Color::Ansi(AnsiColor::White)))) +} diff --git a/crates/toolbox/src/lib.rs b/crates/toolbox/src/lib.rs new file mode 100644 index 0000000..7e803e4 --- /dev/null +++ b/crates/toolbox/src/lib.rs @@ -0,0 +1,11 @@ +//! This crate contains various bits of useful code that don't fit anywhere else. + +pub mod mime; +pub mod misc; +pub mod strings; + +#[cfg(feature = "cli")] +pub mod cli; + +#[cfg(feature = "cli")] +pub mod logging; diff --git a/crates/toolbox/src/logging.rs b/crates/toolbox/src/logging.rs new file mode 100644 index 0000000..91d7593 --- /dev/null +++ b/crates/toolbox/src/logging.rs @@ -0,0 +1,448 @@ +use anyhow::Result; +use clap::{Parser, ValueEnum}; +use serde::Deserialize; +use std::{fmt::Display, str::FromStr}; +use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::SubscriberInitExt}; + +// +// MARK: loglevel +// + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, ValueEnum)] +pub enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +impl Default for LogLevel { + fn default() -> Self { + Self::Info + } +} + +impl Display for LogLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Trace => write!(f, "trace"), + Self::Debug => write!(f, "debug"), + Self::Info => write!(f, "info"), + Self::Warn => write!(f, "warn"), + Self::Error => write!(f, "error"), + } + } +} + +// +// MARK: logconfig +// + +/// Configures log levels for known sources +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)] +pub struct LoggingConfig { + pub other: LogLevel, + pub silence: LogLevel, + + // Libs + pub libservice: LogLevel, + pub toolbox: LogLevel, + + // Bins + pub service: LogLevel, + pub webpage: LogLevel, +} + +impl From for EnvFilter { + fn from(conf: LoggingConfig) -> Self { + // Should never fail + #[expect(clippy::unwrap_used)] + EnvFilter::from_str( + &[ + // + // Silence + // + // http + format!("hyper_util={}", conf.silence), + format!("h2={}", conf.silence), + format!("rustls={}", conf.silence), + format!("tower={}", conf.silence), + // + // Libs + // + format!("toolbox={}", conf.toolbox), + format!("libservice={}", conf.libservice), + // + // Bins + // + format!("service_webpage={}", conf.service), + format!("webpage={}", conf.webpage), + conf.other.to_string(), + ] + .join(","), + ) + .unwrap() + } +} + +// +// MARK: LogCliVQ +// + +/// Provides global -v and -q cli arguments. +/// Use with `#[arg(flatten)]`. +#[derive(Parser, Debug, Clone)] +pub struct LogCliVQ { + /// Increase verbosity (can be repeated) + #[arg(default_value = "0")] + #[arg(short, action = clap::ArgAction::Count,global = true)] + v: u8, + + /// Decrease verbosity (can be repeated) + #[arg(default_value = "0")] + #[arg(short, action = clap::ArgAction::Count, global = true)] + q: u8, +} + +impl LogCliVQ { + pub fn into_preset(&self) -> LogFilterPreset { + let level_i: i16 = self.v as i16 - self.q as i16; + + let preset; + if level_i <= -2 { + preset = LogFilterPreset::Error + } else if level_i == -1 { + preset = LogFilterPreset::Warn + } else if level_i == 0 { + preset = LogFilterPreset::Info + } else if level_i == 1 { + preset = LogFilterPreset::Debug + } else if level_i >= 2 { + preset = LogFilterPreset::Trace + } else { + unreachable!() + } + + return preset; + } +} + +// +// MARK: logpreset +// + +/// Provides preset configurations of [LoggingConfig] +#[derive(Debug, Deserialize, Clone, Copy)] +pub enum LogFilterPreset { + /// Standard "error" log level + Error, + + /// Standard "warn" log level + Warn, + + /// Standard "info" log level. + /// This is the default. + Info, + + /// Standard "debug" log level + Debug, + + /// Standard "trace" log level + Trace, + + /// Filter for loki subscriber. + /// + /// This is similar to `Trace`, + /// but excludes particulary spammy sources. + Loki, +} + +impl Default for LogFilterPreset { + fn default() -> Self { + return Self::Info; + } +} + +impl From for LoggingConfig { + fn from(val: LogFilterPreset) -> Self { + val.get_config() + } +} + +impl LogFilterPreset { + pub fn get_config(&self) -> LoggingConfig { + match self { + Self::Error => LoggingConfig { + other: LogLevel::Error, + silence: LogLevel::Error, + + // Libs + libservice: LogLevel::Error, + toolbox: LogLevel::Error, + + // Bins + webpage: LogLevel::Error, + service: LogLevel::Error, + }, + + Self::Warn => LoggingConfig { + other: LogLevel::Warn, + silence: LogLevel::Warn, + + // Libs + libservice: LogLevel::Warn, + toolbox: LogLevel::Warn, + + // Bins + webpage: LogLevel::Warn, + service: LogLevel::Warn, + }, + + Self::Info => LoggingConfig { + other: LogLevel::Warn, + silence: LogLevel::Warn, + + // Libs + libservice: LogLevel::Info, + toolbox: LogLevel::Info, + + // Bins + webpage: LogLevel::Info, + service: LogLevel::Info, + }, + + Self::Debug => LoggingConfig { + other: LogLevel::Warn, + silence: LogLevel::Warn, + + // Libs + libservice: LogLevel::Debug, + toolbox: LogLevel::Debug, + + // Bins + webpage: LogLevel::Debug, + service: LogLevel::Debug, + }, + + Self::Trace => LoggingConfig { + other: LogLevel::Trace, + silence: LogLevel::Warn, + + // Libs + libservice: LogLevel::Trace, + toolbox: LogLevel::Trace, + + // Bins + webpage: LogLevel::Trace, + service: LogLevel::Trace, + }, + + Self::Loki => LoggingConfig { + other: LogLevel::Trace, + silence: LogLevel::Warn, + + // Libs + libservice: LogLevel::Trace, + toolbox: LogLevel::Trace, + + // Bins + webpage: LogLevel::Trace, + service: LogLevel::Trace, + }, + } + } +} + +// +// MARK: initializer +// + +#[cfg(feature = "loki")] +#[derive(Deserialize, Clone)] +pub struct LokiConfig { + pub loki_host: url::Url, + pub loki_user: String, + pub loki_pass: String, + pub loki_node_name: String, +} + +/// Where to print logs +pub enum LoggingTarget { + /// Send logs to stdout + Stdout { format: LoggingFormat }, + + /// Send logs to stderr + Stderr { format: LoggingFormat }, + /* + /// Send logs to an IndicatifWriter. + /// + /// This is the same as Stderr { format: Ansi {color:true} }, + /// but uses an indicatifwriter with the given multiprogress. + Indicatif(MultiProgress), + */ +} + +/// How to print logs +#[derive(Debug, Clone, Copy, Deserialize)] +pub enum LoggingFormat { + Ansi, + AnsiNoColor, + Json, +} + +impl Default for LoggingFormat { + fn default() -> Self { + Self::Ansi + } +} + +pub struct LoggingInitializer { + pub app_name: &'static str, + + /// If `Some`, send logs to the given loki server + #[cfg(feature = "loki")] + pub loki: Option, + + /// Log filter for printed logs + pub preset: LogFilterPreset, + + /// Where to print logs + pub target: LoggingTarget, +} + +impl LoggingInitializer { + pub fn initialize(self) -> Result<()> { + let mut stderr_ansi_layer = None; + let mut stderr_json_layer = None; + let mut stdout_ansi_layer = None; + let mut stdout_json_layer = None; + match self.target { + LoggingTarget::Stderr { + format: LoggingFormat::Ansi, + } => { + stderr_ansi_layer = Some( + tracing_subscriber::fmt::Layer::default() + .without_time() + .with_ansi(true) + .with_writer(std::io::stderr) + .with_filter::(self.preset.get_config().into()), + ) + } + + LoggingTarget::Stderr { + format: LoggingFormat::AnsiNoColor, + } => { + stderr_ansi_layer = Some( + tracing_subscriber::fmt::Layer::default() + .without_time() + .with_ansi(false) + .with_writer(std::io::stderr) + .with_filter::(self.preset.get_config().into()), + ) + } + + LoggingTarget::Stderr { + format: LoggingFormat::Json, + } => { + stderr_json_layer = Some( + tracing_subscriber::fmt::Layer::default() + .without_time() + .with_ansi(false) + .json() + .with_writer(std::io::stderr) + .with_filter::(self.preset.get_config().into()), + ) + } + + LoggingTarget::Stdout { + format: LoggingFormat::Ansi, + } => { + stdout_ansi_layer = Some( + tracing_subscriber::fmt::Layer::default() + .without_time() + .with_ansi(true) + .with_writer(std::io::stdout) + .with_filter::(self.preset.get_config().into()), + ) + } + + LoggingTarget::Stdout { + format: LoggingFormat::AnsiNoColor, + } => { + stdout_ansi_layer = Some( + tracing_subscriber::fmt::Layer::default() + .without_time() + .with_ansi(false) + .with_writer(std::io::stdout) + .with_filter::(self.preset.get_config().into()), + ) + } + + LoggingTarget::Stdout { + format: LoggingFormat::Json, + } => { + stdout_json_layer = Some( + tracing_subscriber::fmt::Layer::default() + .without_time() + .with_ansi(false) + .json() + .with_writer(std::io::stdout) + .with_filter::(self.preset.get_config().into()), + ) + } /* + LoggingTarget::Indicatif(mp) => { + let writer: IndicatifWriter = + IndicatifWriter::new(mp); + + indicatif_layer = Some( + tracing_subscriber::fmt::Layer::default() + .without_time() + .with_ansi(true) + .with_writer(writer.make_writer()) + .with_filter::(self.preset.get_config().into()), + ) + } + */ + } + + let loki_layer = { + #[cfg(feature = "loki")] + if let Some(cfg) = self.loki { + use anyhow::Context; + use base64::{Engine, prelude::BASE64_STANDARD}; + + let basic_auth = format!("{}:{}", cfg.loki_user, cfg.loki_pass); + let encoded_basic_auth = BASE64_STANDARD.encode(basic_auth.as_bytes()); + + let (layer, task) = tracing_loki::builder() + .label("node_name", cfg.loki_node_name) + .context("while building loki node_name label")? + .label("app", self.app_name) + .context("while building loki app label")? + .http_header("Authorization", format!("Basic {encoded_basic_auth}")) + .context("while building loki header")? + .build_url(cfg.loki_host) + .context("while building loki layer")?; + + tokio::spawn(task); + Some(layer.with_filter::(LogFilterPreset::Loki.get_config().into())) + } else { + None + } + + #[cfg(not(feature = "loki"))] + None:: + Send + Sync>> + }; + + tracing_subscriber::registry() + .with(loki_layer) + .with(stdout_ansi_layer) + .with(stdout_json_layer) + .with(stderr_ansi_layer) + .with(stderr_json_layer) + .init(); + + Ok(()) + } +} diff --git a/crates/toolbox/src/mime.rs b/crates/toolbox/src/mime.rs new file mode 100644 index 0000000..098b1b4 --- /dev/null +++ b/crates/toolbox/src/mime.rs @@ -0,0 +1,668 @@ +use std::{fmt::Display, str::FromStr}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use tracing::debug; + +/// A media type, conveniently parsed +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum MimeType { + /// A mimetype we didn't recognize + Other(String), + + /// An unstructured binary blob + /// Use this whenever a mime type is unknown + Blob, + + // MARK: Audio + /// AAC audio file (audio/aac) + Aac, + /// FLAC audio file (audio/flac) + Flac, + /// MIDI audio file (audio/midi) + Midi, + /// MP3 audio file (audio/mpeg) + Mp3, + /// OGG audio file (audio/ogg) + Oga, + /// Opus audio file in Ogg container (audio/ogg) + Opus, + /// Waveform Audio Format (audio/wav) + Wav, + /// WEBM audio file (audio/webm) + Weba, + + // MARK: Video + /// AVI: Audio Video Interleave (video/x-msvideo) + Avi, + /// MP4 video file (video/mp4) + Mp4, + /// MPEG video file (video/mpeg) + Mpeg, + /// OGG video file (video/ogg) + Ogv, + /// MPEG transport stream (video/mp2t) + Ts, + /// WEBM video file (video/webm) + WebmVideo, + /// 3GPP audio/video container (video/3gpp) + ThreeGp, + /// 3GPP2 audio/video container (video/3gpp2) + ThreeG2, + + // MARK: Images + /// Animated Portable Network Graphics (image/apng) + Apng, + /// AVIF image (image/avif) + Avif, + /// Windows OS/2 Bitmap Graphics (image/bmp) + Bmp, + /// Graphics Interchange Format (image/gif) + Gif, + /// Icon format (image/vnd.microsoft.icon) + Ico, + /// JPEG image (image/jpeg) + Jpg, + /// Portable Network Graphics (image/png) + Png, + /// Scalable Vector Graphics (image/svg+xml) + Svg, + /// Tagged Image File Format (image/tiff) + Tiff, + /// WEBP image (image/webp) + Webp, + + // MARK: Text + /// Plain text (text/plain) + Text, + /// Cascading Style Sheets (text/css) + Css, + /// Comma-separated values (text/csv) + Csv, + /// HyperText Markup Language (text/html) + Html, + /// JavaScript (text/javascript) + Javascript, + /// JSON format (application/json) + Json, + /// JSON-LD format (application/ld+json) + JsonLd, + /// XML (application/xml) + Xml, + + // MARK: Documents + /// Adobe Portable Document Format (application/pdf) + Pdf, + /// Rich Text Format (application/rtf) + Rtf, + + // MARK: Archives + /// Archive document, multiple files embedded (application/x-freearc) + Arc, + /// BZip archive (application/x-bzip) + Bz, + /// BZip2 archive (application/x-bzip2) + Bz2, + /// GZip Compressed Archive (application/gzip) + Gz, + /// Java Archive (application/java-archive) + Jar, + /// OGG (application/ogg) + Ogg, + /// RAR archive (application/vnd.rar) + Rar, + /// 7-zip archive (application/x-7z-compressed) + SevenZ, + /// Tape Archive (application/x-tar) + Tar, + /// ZIP archive (application/zip) + Zip, + + // MARK: Fonts + /// MS Embedded OpenType fonts (application/vnd.ms-fontobject) + Eot, + /// OpenType font (font/otf) + Otf, + /// TrueType Font (font/ttf) + Ttf, + /// Web Open Font Format (font/woff) + Woff, + /// Web Open Font Format 2 (font/woff2) + Woff2, + + // MARK: Applications + /// AbiWord document (application/x-abiword) + Abiword, + /// Amazon Kindle eBook format (application/vnd.amazon.ebook) + Azw, + /// CD audio (application/x-cdf) + Cda, + /// C-Shell script (application/x-csh) + Csh, + /// Microsoft Word (application/msword) + Doc, + /// Microsoft Word OpenXML (application/vnd.openxmlformats-officedocument.wordprocessingml.document) + Docx, + /// Electronic publication (application/epub+zip) + Epub, + /// iCalendar format (text/calendar) + Ics, + /// Apple Installer Package (application/vnd.apple.installer+xml) + Mpkg, + /// OpenDocument presentation (application/vnd.oasis.opendocument.presentation) + Odp, + /// OpenDocument spreadsheet (application/vnd.oasis.opendocument.spreadsheet) + Ods, + /// OpenDocument text document (application/vnd.oasis.opendocument.text) + Odt, + /// Hypertext Preprocessor (application/x-httpd-php) + Php, + /// Microsoft PowerPoint (application/vnd.ms-powerpoint) + Ppt, + /// Microsoft PowerPoint OpenXML (application/vnd.openxmlformats-officedocument.presentationml.presentation) + Pptx, + /// Bourne shell script (application/x-sh) + Sh, + /// Microsoft Visio (application/vnd.visio) + Vsd, + /// XHTML (application/xhtml+xml) + Xhtml, + /// Microsoft Excel (application/vnd.ms-excel) + Xls, + /// Microsoft Excel OpenXML (application/vnd.openxmlformats-officedocument.spreadsheetml.sheet) + Xlsx, + /// XUL (application/vnd.mozilla.xul+xml) + Xul, +} + +// MARK: ser/de + +/* +impl utoipa::ToSchema for MimeType { + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("MimeType") + } +} +impl utoipa::PartialSchema for MimeType { + fn schema() -> utoipa::openapi::RefOr { + utoipa::openapi::Schema::Object( + utoipa::openapi::schema::ObjectBuilder::new() + .schema_type(utoipa::openapi::schema::SchemaType::Type(Type::String)) + .description(Some( + "A media type string (e.g., 'application/json', 'text/plain')", + )) + .examples(Some("application/json")) + .build(), + ) + .into() + } +} +*/ + +impl Serialize for MimeType { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for MimeType { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(MimeType::from_str(&s).unwrap()) + } +} + +// MARK: misc + +impl Default for MimeType { + fn default() -> Self { + Self::Blob + } +} + +impl From for MimeType { + fn from(value: String) -> Self { + Self::from_str(&value).unwrap() + } +} + +impl From<&str> for MimeType { + fn from(value: &str) -> Self { + Self::from_str(value).unwrap() + } +} + +impl From<&MimeType> for String { + fn from(value: &MimeType) -> Self { + value.to_string() + } +} + +impl FromStr for MimeType { + type Err = std::convert::Infallible; + + // Must match `display` below, but may provide other alternatives. + fn from_str(s: &str) -> Result { + Ok(match s { + "application/octet-stream" => Self::Blob, + + // MARK: Audio + "audio/aac" => Self::Aac, + "audio/flac" => Self::Flac, + "audio/midi" | "audio/x-midi" => Self::Midi, + "audio/mpeg" => Self::Mp3, + "audio/ogg" => Self::Oga, + "audio/wav" => Self::Wav, + "audio/webm" => Self::Weba, + + // MARK: Video + "video/x-msvideo" => Self::Avi, + "video/mp4" => Self::Mp4, + "video/mpeg" => Self::Mpeg, + "video/ogg" => Self::Ogv, + "video/mp2t" => Self::Ts, + "video/webm" => Self::WebmVideo, + "video/3gpp" => Self::ThreeGp, + "video/3gpp2" => Self::ThreeG2, + + // MARK: Images + "image/apng" => Self::Apng, + "image/avif" => Self::Avif, + "image/bmp" => Self::Bmp, + "image/gif" => Self::Gif, + "image/vnd.microsoft.icon" => Self::Ico, + "image/jpeg" | "image/jpg" => Self::Jpg, + "image/png" => Self::Png, + "image/svg+xml" => Self::Svg, + "image/tiff" => Self::Tiff, + "image/webp" => Self::Webp, + + // MARK: Text + "text/plain" => Self::Text, + "text/css" => Self::Css, + "text/csv" => Self::Csv, + "text/html" => Self::Html, + "text/javascript" => Self::Javascript, + "application/json" => Self::Json, + "application/ld+json" => Self::JsonLd, + "application/xml" | "text/xml" => Self::Xml, + + // MARK: Documents + "application/pdf" => Self::Pdf, + "application/rtf" => Self::Rtf, + + // MARK: Archives + "application/x-freearc" => Self::Arc, + "application/x-bzip" => Self::Bz, + "application/x-bzip2" => Self::Bz2, + "application/gzip" | "application/x-gzip" => Self::Gz, + "application/java-archive" => Self::Jar, + "application/ogg" => Self::Ogg, + "application/vnd.rar" => Self::Rar, + "application/x-7z-compressed" => Self::SevenZ, + "application/x-tar" => Self::Tar, + "application/zip" | "application/x-zip-compressed" => Self::Zip, + + // MARK: Fonts + "application/vnd.ms-fontobject" => Self::Eot, + "font/otf" => Self::Otf, + "font/ttf" => Self::Ttf, + "font/woff" => Self::Woff, + "font/woff2" => Self::Woff2, + + // MARK: Applications + "application/x-abiword" => Self::Abiword, + "application/vnd.amazon.ebook" => Self::Azw, + "application/x-cdf" => Self::Cda, + "application/x-csh" => Self::Csh, + "application/msword" => Self::Doc, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => Self::Docx, + "application/epub+zip" => Self::Epub, + "text/calendar" => Self::Ics, + "application/vnd.apple.installer+xml" => Self::Mpkg, + "application/vnd.oasis.opendocument.presentation" => Self::Odp, + "application/vnd.oasis.opendocument.spreadsheet" => Self::Ods, + "application/vnd.oasis.opendocument.text" => Self::Odt, + "application/x-httpd-php" => Self::Php, + "application/vnd.ms-powerpoint" => Self::Ppt, + "application/vnd.openxmlformats-officedocument.presentationml.presentation" => { + Self::Pptx + } + "application/x-sh" => Self::Sh, + "application/vnd.visio" => Self::Vsd, + "application/xhtml+xml" => Self::Xhtml, + "application/vnd.ms-excel" => Self::Xls, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => Self::Xlsx, + "application/vnd.mozilla.xul+xml" => Self::Xul, + + _ => { + debug!(message = "Encountered unknown mimetype", mime_string = s); + Self::Other(s.into()) + } + }) + } +} + +impl Display for MimeType { + /// Get a string representation of this mimetype. + /// + /// The following always holds: + /// ```rust + /// # use toolbox::mime::MimeType; + /// # let x = MimeType::Blob; + /// assert_eq!(MimeType::from(x.to_string()), x); + /// ``` + /// + /// The following might not hold: + /// ```rust + /// # use toolbox::mime::MimeType; + /// # let y = "application/custom"; + /// // MimeType::from(y).to_string() may not equal y + /// ``` + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Blob => write!(f, "application/octet-stream"), + + // MARK: Audio + Self::Aac => write!(f, "audio/aac"), + Self::Flac => write!(f, "audio/flac"), + Self::Midi => write!(f, "audio/midi"), + Self::Mp3 => write!(f, "audio/mpeg"), + Self::Oga => write!(f, "audio/ogg"), + Self::Opus => write!(f, "audio/ogg"), + Self::Wav => write!(f, "audio/wav"), + Self::Weba => write!(f, "audio/webm"), + + // MARK: Video + Self::Avi => write!(f, "video/x-msvideo"), + Self::Mp4 => write!(f, "video/mp4"), + Self::Mpeg => write!(f, "video/mpeg"), + Self::Ogv => write!(f, "video/ogg"), + Self::Ts => write!(f, "video/mp2t"), + Self::WebmVideo => write!(f, "video/webm"), + Self::ThreeGp => write!(f, "video/3gpp"), + Self::ThreeG2 => write!(f, "video/3gpp2"), + + // MARK: Images + Self::Apng => write!(f, "image/apng"), + Self::Avif => write!(f, "image/avif"), + Self::Bmp => write!(f, "image/bmp"), + Self::Gif => write!(f, "image/gif"), + Self::Ico => write!(f, "image/vnd.microsoft.icon"), + Self::Jpg => write!(f, "image/jpeg"), + Self::Png => write!(f, "image/png"), + Self::Svg => write!(f, "image/svg+xml"), + Self::Tiff => write!(f, "image/tiff"), + Self::Webp => write!(f, "image/webp"), + + // MARK: Text + Self::Text => write!(f, "text/plain"), + Self::Css => write!(f, "text/css"), + Self::Csv => write!(f, "text/csv"), + Self::Html => write!(f, "text/html"), + Self::Javascript => write!(f, "text/javascript"), + Self::Json => write!(f, "application/json"), + Self::JsonLd => write!(f, "application/ld+json"), + Self::Xml => write!(f, "application/xml"), + + // MARK: Documents + Self::Pdf => write!(f, "application/pdf"), + Self::Rtf => write!(f, "application/rtf"), + + // MARK: Archives + Self::Arc => write!(f, "application/x-freearc"), + Self::Bz => write!(f, "application/x-bzip"), + Self::Bz2 => write!(f, "application/x-bzip2"), + Self::Gz => write!(f, "application/gzip"), + Self::Jar => write!(f, "application/java-archive"), + Self::Ogg => write!(f, "application/ogg"), + Self::Rar => write!(f, "application/vnd.rar"), + Self::SevenZ => write!(f, "application/x-7z-compressed"), + Self::Tar => write!(f, "application/x-tar"), + Self::Zip => write!(f, "application/zip"), + + // MARK: Fonts + Self::Eot => write!(f, "application/vnd.ms-fontobject"), + Self::Otf => write!(f, "font/otf"), + Self::Ttf => write!(f, "font/ttf"), + Self::Woff => write!(f, "font/woff"), + Self::Woff2 => write!(f, "font/woff2"), + + // MARK: Applications + Self::Abiword => write!(f, "application/x-abiword"), + Self::Azw => write!(f, "application/vnd.amazon.ebook"), + Self::Cda => write!(f, "application/x-cdf"), + Self::Csh => write!(f, "application/x-csh"), + Self::Doc => write!(f, "application/msword"), + Self::Docx => write!( + f, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ), + Self::Epub => write!(f, "application/epub+zip"), + Self::Ics => write!(f, "text/calendar"), + Self::Mpkg => write!(f, "application/vnd.apple.installer+xml"), + Self::Odp => write!(f, "application/vnd.oasis.opendocument.presentation"), + Self::Ods => write!(f, "application/vnd.oasis.opendocument.spreadsheet"), + Self::Odt => write!(f, "application/vnd.oasis.opendocument.text"), + Self::Php => write!(f, "application/x-httpd-php"), + Self::Ppt => write!(f, "application/vnd.ms-powerpoint"), + Self::Pptx => write!( + f, + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ), + Self::Sh => write!(f, "application/x-sh"), + Self::Vsd => write!(f, "application/vnd.visio"), + Self::Xhtml => write!(f, "application/xhtml+xml"), + Self::Xls => write!(f, "application/vnd.ms-excel"), + Self::Xlsx => write!( + f, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + Self::Xul => write!(f, "application/vnd.mozilla.xul+xml"), + + Self::Other(x) => write!(f, "{x}"), + } + } +} + +impl MimeType { + // Must match `From` above + + /// Try to guess a file's mime type from its extension. + /// `ext` should NOT start with a dot. + pub fn from_extension(ext: &str) -> Option { + Some(match ext { + // MARK: Audio + "aac" => Self::Aac, + "flac" => Self::Flac, + "mid" | "midi" => Self::Midi, + "mp3" => Self::Mp3, + "oga" => Self::Oga, + "opus" => Self::Opus, + "wav" => Self::Wav, + "weba" => Self::Weba, + + // MARK: Video + "avi" => Self::Avi, + "mp4" => Self::Mp4, + "mpeg" => Self::Mpeg, + "ogv" => Self::Ogv, + "ts" => Self::Ts, + "webm" => Self::WebmVideo, + "3gp" => Self::ThreeGp, + "3g2" => Self::ThreeG2, + + // MARK: Images + "apng" => Self::Apng, + "avif" => Self::Avif, + "bmp" => Self::Bmp, + "gif" => Self::Gif, + "ico" => Self::Ico, + "jpg" | "jpeg" => Self::Jpg, + "png" => Self::Png, + "svg" => Self::Svg, + "tif" | "tiff" => Self::Tiff, + "webp" => Self::Webp, + + // MARK: Text + "txt" => Self::Text, + "css" => Self::Css, + "csv" => Self::Csv, + "htm" | "html" => Self::Html, + "js" | "mjs" => Self::Javascript, + "json" => Self::Json, + "jsonld" => Self::JsonLd, + "xml" => Self::Xml, + + // MARK: Documents + "pdf" => Self::Pdf, + "rtf" => Self::Rtf, + + // MARK: Archives + "arc" => Self::Arc, + "bz" => Self::Bz, + "bz2" => Self::Bz2, + "gz" => Self::Gz, + "jar" => Self::Jar, + "ogx" => Self::Ogg, + "rar" => Self::Rar, + "7z" => Self::SevenZ, + "tar" => Self::Tar, + "zip" => Self::Zip, + + // MARK: Fonts + "eot" => Self::Eot, + "otf" => Self::Otf, + "ttf" => Self::Ttf, + "woff" => Self::Woff, + "woff2" => Self::Woff2, + + // MARK: Applications + "abw" => Self::Abiword, + "azw" => Self::Azw, + "cda" => Self::Cda, + "csh" => Self::Csh, + "doc" => Self::Doc, + "docx" => Self::Docx, + "epub" => Self::Epub, + "ics" => Self::Ics, + "mpkg" => Self::Mpkg, + "odp" => Self::Odp, + "ods" => Self::Ods, + "odt" => Self::Odt, + "php" => Self::Php, + "ppt" => Self::Ppt, + "pptx" => Self::Pptx, + "sh" => Self::Sh, + "vsd" => Self::Vsd, + "xhtml" => Self::Xhtml, + "xls" => Self::Xls, + "xlsx" => Self::Xlsx, + "xul" => Self::Xul, + + _ => return None, + }) + } + + /// Get the extension we use for files with this type. + /// Includes a dot. Might be the empty string. + pub fn extension(&self) -> &str { + match self { + Self::Blob => "", + Self::Other(_) => "", + + // MARK: Audio + Self::Aac => ".aac", + Self::Flac => ".flac", + Self::Midi => ".midi", + Self::Mp3 => ".mp3", + Self::Oga => ".oga", + Self::Opus => ".opus", + Self::Wav => ".wav", + Self::Weba => ".weba", + + // MARK: Video + Self::Avi => ".avi", + Self::Mp4 => ".mp4", + Self::Mpeg => ".mpeg", + Self::Ogv => ".ogv", + Self::Ts => ".ts", + Self::WebmVideo => ".webm", + Self::ThreeGp => ".3gp", + Self::ThreeG2 => ".3g2", + + // MARK: Images + Self::Apng => ".apng", + Self::Avif => ".avif", + Self::Bmp => ".bmp", + Self::Gif => ".gif", + Self::Ico => ".ico", + Self::Jpg => ".jpg", + Self::Png => ".png", + Self::Svg => ".svg", + Self::Tiff => ".tiff", + Self::Webp => ".webp", + + // MARK: Text + Self::Text => ".txt", + Self::Css => ".css", + Self::Csv => ".csv", + Self::Html => ".html", + Self::Javascript => ".js", + Self::Json => ".json", + Self::JsonLd => ".jsonld", + Self::Xml => ".xml", + + // MARK: Documents + Self::Pdf => ".pdf", + Self::Rtf => ".rtf", + + // MARK: Archives + Self::Arc => ".arc", + Self::Bz => ".bz", + Self::Bz2 => ".bz2", + Self::Gz => ".gz", + Self::Jar => ".jar", + Self::Ogg => ".ogx", + Self::Rar => ".rar", + Self::SevenZ => ".7z", + Self::Tar => ".tar", + Self::Zip => ".zip", + + // MARK: Fonts + Self::Eot => ".eot", + Self::Otf => ".otf", + Self::Ttf => ".ttf", + Self::Woff => ".woff", + Self::Woff2 => ".woff2", + + // MARK: Applications + Self::Abiword => ".abw", + Self::Azw => ".azw", + Self::Cda => ".cda", + Self::Csh => ".csh", + Self::Doc => ".doc", + Self::Docx => ".docx", + Self::Epub => ".epub", + Self::Ics => ".ics", + Self::Mpkg => ".mpkg", + Self::Odp => ".odp", + Self::Ods => ".ods", + Self::Odt => ".odt", + Self::Php => ".php", + Self::Ppt => ".ppt", + Self::Pptx => ".pptx", + Self::Sh => ".sh", + Self::Vsd => ".vsd", + Self::Xhtml => ".xhtml", + Self::Xls => ".xls", + Self::Xlsx => ".xlsx", + Self::Xul => ".xul", + } + } +} diff --git a/crates/toolbox/src/misc.rs b/crates/toolbox/src/misc.rs new file mode 100644 index 0000000..68cb553 --- /dev/null +++ b/crates/toolbox/src/misc.rs @@ -0,0 +1,36 @@ +/// Normalize a domain. This does the following: +/// - removes protocol prefixes +/// - removes leading `www` +/// - removes query params and path segments. +/// +/// This function is for roach, and should exactly match the ts implementation. +/// +/// ## Examples: +/// ``` +/// # use toolbox::misc::normalize_domain; +/// assert_eq!("domain.com", normalize_domain("domain.com")); +/// assert_eq!("domain.com", normalize_domain("domain.com/")); +/// assert_eq!("domain.com", normalize_domain("domain.com/en/us")); +/// assert_eq!("domain.com", normalize_domain("domain.com/?key=val")); +/// assert_eq!("domain.com", normalize_domain("www.domain.com")); +/// assert_eq!("domain.com", normalize_domain("https://www.domain.com")); +/// assert_eq!("us.domain.com", normalize_domain("us.domain.com")); +/// ``` +pub fn normalize_domain(domain: &str) -> &str { + let mut domain = domain.strip_prefix("http://").unwrap_or(domain); + domain = domain.strip_prefix("https://").unwrap_or(domain); + domain = domain.strip_prefix("www.").unwrap_or(domain); + domain = domain.find("/").map_or(domain, |x| &domain[0..x]); + + return domain; +} + +/* +pub fn random_string(length: usize) -> String { + rand::rng() + .sample_iter(&Alphanumeric) + .take(length) + .map(char::from) + .collect() +} +*/ diff --git a/crates/toolbox/src/strings.rs b/crates/toolbox/src/strings.rs new file mode 100644 index 0000000..cfbe23c --- /dev/null +++ b/crates/toolbox/src/strings.rs @@ -0,0 +1,136 @@ +use std::fmt::Display; + +use num::{Integer, Zero, cast::AsPrimitive}; + +/// Pretty-print a quantity of bytes +pub fn pp_bytes_si>(bytes: T) -> String { + const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]; + const THRESHOLD: f64 = 1000.0; + + if bytes.is_zero() { + return "0 B".to_owned(); + } + + let bytes_f: f64 = bytes.as_(); + let unit_index = (bytes_f.log10() / THRESHOLD.log10()).floor() as usize; + let unit_index = unit_index.min(UNITS.len() - 1); + + let value = bytes_f / THRESHOLD.powi(unit_index as i32); + + if unit_index == 0 { + format!("{} {}", bytes, UNITS[unit_index]) + } else if value >= 100.0 { + format!("{:.0} {}", value, UNITS[unit_index]) + } else if value >= 10.0 { + format!("{:.1} {}", value, UNITS[unit_index]) + } else { + format!("{:.2} {}", value, UNITS[unit_index]) + } +} + +/// Pretty-print a quantity of bytes +pub fn pp_bytes_b2>(bytes: T) -> String { + const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]; + const THRESHOLD: f64 = 1024.0; + + if bytes.is_zero() { + return "0 B".to_owned(); + } + + let bytes_f: f64 = bytes.as_(); + let unit_index = (bytes_f.log2() / THRESHOLD.log2()).floor() as usize; + let unit_index = unit_index.min(UNITS.len() - 1); + + let value = bytes_f / THRESHOLD.powi(unit_index as i32); + + if unit_index == 0 { + format!("{} {}", bytes, UNITS[unit_index]) + } else if value >= 100.0 { + format!("{:.0} {}", value, UNITS[unit_index]) + } else if value >= 10.0 { + format!("{:.1} {}", value, UNITS[unit_index]) + } else { + format!("{:.2} {}", value, UNITS[unit_index]) + } +} + +/// Pretty-print an integer with comma separators +pub fn pp_int_commas(value: T) -> String { + let s = value.to_string(); + let mut result = String::new(); + let bytes = s.as_bytes(); + + for (i, &byte) in bytes.iter().enumerate() { + if i > 0 && (bytes.len() - i).is_multiple_of(3) { + result.push(','); + } + result.push(byte as char); + } + + result +} + +/// Convert bytes to a hex string +#[inline] +pub fn to_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +/// Parse a hex string as bytes +pub fn from_hex(hex_str: &str) -> Result, &'static str> { + if !hex_str.len().is_multiple_of(2) { + return Err("hex string must have even length"); + } + + let mut bytes = Vec::with_capacity(hex_str.len() / 2); + + for chunk in hex_str.as_bytes().chunks(2) { + let hex_byte = std::str::from_utf8(chunk).map_err(|_err| "invalid UTF-8 in hex string")?; + let byte = u8::from_str_radix(hex_byte, 16).map_err(|_err| "invalid hex character")?; + bytes.push(byte); + } + + Ok(bytes) +} + +/// Truncate a string from the left, replacing the first character with "…" if truncated +pub fn truncate_left(length: usize, string: &str) -> String { + if string.chars().count() <= length { + return string.to_owned(); + } + + if length == 0 { + return String::new(); + } + + if length == 1 { + return "…".to_owned(); + } + + let chars: Vec = string.chars().collect(); + let start_index = chars.len() - (length - 1); + let mut result = String::from("…"); + result.extend(&chars[start_index..]); + result +} + +/// Truncate a string from the right, replacing the last character with "…" if truncated +pub fn truncate_right(length: usize, string: &str) -> String { + if string.chars().count() <= length { + return string.to_owned(); + } + + if length == 0 { + return String::new(); + } + + if length == 1 { + return "…".to_owned(); + } + + let chars: Vec = string.chars().collect(); + let mut result = String::new(); + result.extend(&chars[0..length - 1]); + result.push('…'); + result +}