diff --git a/Cargo.lock b/Cargo.lock index 5416445..93de9b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2783,7 +2783,6 @@ dependencies = [ "toml 0.8.13", "tracing", "tracing-subscriber", - "unicode-ellipsis", ] [[package]] @@ -3290,16 +3289,6 @@ version = "2.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" -[[package]] -name = "unicode-ellipsis" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ab5b8c3fed9966b8cde2dc7169146331cba3dacba97cbd0e8866e7cfd4dff" -dependencies = [ - "unicode-segmentation", - "unicode-width", -] - [[package]] name = "unicode-ident" version = "1.0.12" @@ -3315,12 +3304,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "unicode-segmentation" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" - [[package]] name = "unicode-width" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index 63f216a..e27e1f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ cadence = "1" crates-index = { version = "2", default-features = false, features = ["git"] } derive_more = "0.99" dotenvy = "0.15" -either = "1.12.0" +either = "1.12" font-awesome-as-a-crate = "0.3" futures-util = { version = "0.3", default-features = false, features = ["std"] } hyper = { version = "0.14.10", features = ["full"] } @@ -34,15 +34,14 @@ relative-path = { version = "1", features = ["serde"] } reqwest = { version = "0.12", features = ["json"] } route-recognizer = "0.3" rustsec = "0.29" -semver = { version = "1.0", features = ["serde"] } +semver = { version = "1", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_urlencoded = "0.7" -serde_with = "3.8.1" +serde_with = "3" tokio = { version = "1.24.2", features = ["rt-multi-thread", "macros", "sync", "time"] } toml = "0.8" tracing = "0.1.30" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -unicode-ellipsis = "0.2.0" [target.'cfg(any())'.dependencies] gix = { version = "0.63", default-features = false, features = ["blocking-http-transport-reqwest-rust-tls"] } diff --git a/src/server/mod.rs b/src/server/mod.rs index a6651c2..127bd49 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -10,7 +10,6 @@ use once_cell::sync::Lazy; use route_recognizer::{Params, Router}; use semver::VersionReq; use serde::Deserialize; -use unicode_ellipsis::truncate_str; mod assets; mod views; @@ -25,9 +24,11 @@ use crate::{ repo::RepoPath, SubjectPath, }, - utils::common::{UntaggedEither, WrappedBool}, + utils::common::{safe_truncate, UntaggedEither, WrappedBool}, }; +const MAX_SUBJECT_WIDTH: usize = 100; + #[derive(Debug, Clone, Copy, PartialEq)] enum StatusFormat { Html, @@ -430,10 +431,13 @@ static SELF_BASE_URL: Lazy = pub struct ExtraConfig { /// Badge style to show style: BadgeStyle, + /// Whether the inscription _"dependencies"_ should be abbreviated as _"deps"_ in the badge. compact: bool, + /// Custom text on the left (it's the same concept as `label` in shields.io). subject: Option, + /// Path in which the crate resides within the repository path: Option, } @@ -447,7 +451,7 @@ impl ExtraConfig { impl QueryParam { fn opt(self) -> Option { - self.0.into_either().left() + either::Either::from(self.0).left() } } @@ -459,7 +463,6 @@ impl ExtraConfig { path: Option, } - const MAX_WIDTH: usize = 100; let extra_config = qs .and_then(|qs| serde_urlencoded::from_str::(qs).ok()) .unwrap_or_default(); @@ -477,8 +480,21 @@ impl ExtraConfig { subject: extra_config .subject .filter(|t| !t.is_empty()) - .map(|t| truncate_str(&t, MAX_WIDTH).into()), + .map(|subject| safe_truncate(&subject, MAX_SUBJECT_WIDTH).to_owned()), path: extra_config.path, } } + + /// Returns subject for badge. + /// + /// Returns `subject` if set, or "dependencies" / "deps" depending on value of `compact`. + pub(crate) fn subject(&self) -> &str { + if let Some(subject) = &self.subject { + subject + } else if self.compact { + "deps" + } else { + "dependencies" + } + } } diff --git a/src/server/views/badge.rs b/src/server/views/badge.rs index f97f829..760c7b9 100644 --- a/src/server/views/badge.rs +++ b/src/server/views/badge.rs @@ -7,13 +7,7 @@ pub fn badge( analysis_outcome: Option<&AnalyzeDependenciesOutcome>, badge_knobs: ExtraConfig, ) -> Badge { - let subject = if let Some(subject) = badge_knobs.subject { - subject - } else if badge_knobs.compact { - "deps".into() - } else { - "dependencies".into() - }; + let subject = badge_knobs.subject().to_owned(); let opts = match analysis_outcome { Some(outcome) => { diff --git a/src/utils/common.rs b/src/utils/common.rs index efd2c6f..5fa757b 100644 --- a/src/utils/common.rs +++ b/src/utils/common.rs @@ -41,31 +41,19 @@ impl From> for UntaggedEither { } } -impl UntaggedEither { - pub fn into_either(self) -> Either { - self.into() - } -} - /// A generic newtype which serialized using `Display` and deserialized using `FromStr`. #[derive(Default, Clone, DeserializeFromStr, SerializeDisplay)] pub struct SerdeDisplayFromStr(pub T); -impl From for SerdeDisplayFromStr { - fn from(value: T) -> Self { - Self(value) - } -} - impl Debug for SerdeDisplayFromStr { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - self.0.fmt(f) + Debug::fmt(&self.0, f) } } impl Display for SerdeDisplayFromStr { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - self.0.fmt(f) + Display::fmt(&self.0, f) } } @@ -73,7 +61,7 @@ impl FromStr for SerdeDisplayFromStr { type Err = T::Err; fn from_str(s: &str) -> Result { - Ok(s.parse::()?.into()) + s.parse::().map(Self) } } @@ -83,3 +71,80 @@ impl FromStr for SerdeDisplayFromStr { /// are used. The Wrap type here forces the deserialization process to /// be delegated to `FromStr`. pub type WrappedBool = SerdeDisplayFromStr; + +/// Returns truncated string accounting for multi-byte characters. +pub(crate) fn safe_truncate(s: &str, len: usize) -> &str { + if len == 0 { + return ""; + } + + if s.len() <= len { + return s; + } + + if s.is_char_boundary(len) { + return &s[0..len]; + } + + // Only 3 cases possible: 1, 2, or 3 bytes need to be removed for a new, + // valid UTF-8 string to appear when truncated, just enumerate them, + // Underflow is not possible since position 0 is always a valid boundary. + + if let Some((slice, _rest)) = s.split_at_checked(len - 1) { + return slice; + } + + if let Some((slice, _rest)) = s.split_at_checked(len - 2) { + return slice; + } + + if let Some((slice, _rest)) = s.split_at_checked(len - 3) { + return slice; + } + + unreachable!("all branches covered"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn safe_truncation() { + assert_eq!(safe_truncate("", 0), ""); + assert_eq!(safe_truncate("", 1), ""); + assert_eq!(safe_truncate("", 9), ""); + + assert_eq!(safe_truncate("a", 0), ""); + assert_eq!(safe_truncate("a", 1), "a"); + assert_eq!(safe_truncate("a", 9), "a"); + + assert_eq!(safe_truncate("lorem\nipsum", 0), ""); + assert_eq!(safe_truncate("lorem\nipsum", 5), "lorem"); + assert_eq!(safe_truncate("lorem\nipsum", usize::MAX), "lorem\nipsum"); + + assert_eq!(safe_truncate("café", 1), "c"); + assert_eq!(safe_truncate("café", 2), "ca"); + assert_eq!(safe_truncate("café", 3), "caf"); + assert_eq!(safe_truncate("café", 4), "caf"); + assert_eq!(safe_truncate("café", 5), "café"); + + // 2-byte char + assert_eq!(safe_truncate("é", 0), ""); + assert_eq!(safe_truncate("é", 1), ""); + assert_eq!(safe_truncate("é", 2), "é"); + + // 3-byte char + assert_eq!(safe_truncate("⊕", 0), ""); + assert_eq!(safe_truncate("⊕", 1), ""); + assert_eq!(safe_truncate("⊕", 2), ""); + assert_eq!(safe_truncate("⊕", 3), "⊕"); + + // 4-byte char + assert_eq!(safe_truncate("🦊", 0), ""); + assert_eq!(safe_truncate("🦊", 1), ""); + assert_eq!(safe_truncate("🦊", 2), ""); + assert_eq!(safe_truncate("🦊", 3), ""); + assert_eq!(safe_truncate("🦊", 4), "🦊"); + } +}