Compare commits

...

7 commits

Author SHA1 Message Date
Rob Ede
7c50788d59
refactor: migrate web server to Actix Web 2024-09-19 20:56:39 +01:00
dependabot[bot]
ff6d9e880f
Bump quinn-proto from 0.11.6 to 0.11.8 (#237)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-04 21:34:39 +01:00
Paolo Barbolini
b016ff1f8f
Bump dependencies (#236) 2024-08-31 17:57:25 +02:00
Rob Ede
5a215ebbfb
refactor: remove unicode dep and simplify UntaggedEither impl 2024-08-07 03:14:50 +01:00
29
62891bb2db
Support custom subject text (#231) 2024-08-07 03:14:37 +01:00
dependabot[bot]
e720a5f4b5
Bump gix-attributes from 0.22.2 to 0.22.3 (#234)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-26 11:10:39 +01:00
dependabot[bot]
9c5c5f88e0
Bump openssl from 0.10.64 to 0.10.66 (#233) 2024-07-22 19:23:22 +01:00
22 changed files with 2323 additions and 1155 deletions

2252
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -14,29 +14,32 @@ edition = "2021"
[dependencies]
badge = { path = "./libs/badge" }
actix-web = "4"
actix-web-lab = "0.20"
anyhow = "1"
cadence = "1"
crates-index = { version = "2", default-features = false, features = ["git"] }
derive_more = "0.99"
crates-index = { version = "3", default-features = false, features = ["git"] }
derive_more = { version = "1", features = ["display", "error", "from"] }
dotenvy = "0.15"
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"] }
error_reporter = "1"
indexmap = { version = "2", features = ["serde"] }
lru_time_cache = "0.11"
maud = "0.26"
mime = "0.3"
once_cell = "1"
parking_lot = "0.12"
pulldown-cmark = "0.11"
pulldown-cmark = "0.12"
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"
tokio = { version = "1.24.2", features = ["rt-multi-thread", "macros", "sync", "time"] }
serde_with = "3"
tokio = { version = "1.24.2", features = ["rt", "macros", "sync", "time"] }
toml = "0.8"
tracing = "0.1.30"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View file

@ -19,10 +19,15 @@ To analyze the state of your dependencies you can use the following URLs:
On the analysis page, you will also find the markdown code to include a fancy badge in your project README so visitors (and you) can see at a glance if your dependencies are still up to date!
Badges have a few style options, specified with query parameters, that match the styles from `shields.io`:
- `?style=flat` (default)
- `?style=flat-square`
- `?style=for-the-badge`
Badges have a few options, specified with query parameters:
- `style`: which matches the styles from `shields.io`:
- `?style=flat` (default)
- `?style=flat-square`
- `?style=for-the-badge`
- `subject`: customize the text on the left (which is the same concept as `label` in `shields.io`, and [URL-Encoding](https://developer.mozilla.org/en-US/docs/Glossary/percent-encoding) is needed for spaces or special characters!). e.g.:
- `?subject=yourdeps`
- `?subject=git%20deps`
- `?subject=deps%3Acore`
## Contributing

View file

@ -1,5 +1,7 @@
use anyhow::Error;
use futures_util::{future::BoxFuture, stream::FuturesOrdered, FutureExt as _, StreamExt as _};
use futures_util::{
future::LocalBoxFuture, stream::FuturesOrdered, FutureExt as _, StreamExt as _,
};
use relative_path::RelativePathBuf;
use crate::{
@ -16,8 +18,9 @@ pub async fn crawl_manifest(
entry_point: RelativePathBuf,
) -> anyhow::Result<ManifestCrawlerOutput> {
let mut crawler = ManifestCrawler::new();
let mut futures: FuturesOrdered<BoxFuture<'static, Result<(RelativePathBuf, String), Error>>> =
FuturesOrdered::new();
let mut futures: FuturesOrdered<
LocalBoxFuture<'static, Result<(RelativePathBuf, String), Error>>,
> = FuturesOrdered::new();
let engine2 = engine.clone();
let repo_path2 = repo_path.clone();
@ -28,7 +31,7 @@ pub async fn crawl_manifest(
.await?;
Ok((entry_point, contents))
}
.boxed();
.boxed_local();
futures.push_back(fut);
@ -47,7 +50,7 @@ pub async fn crawl_manifest(
let contents = engine.retrieve_manifest_at_path(&repo_path, &path).await?;
Ok((path, contents))
}
.boxed();
.boxed_local();
futures.push_back(fut);
}

View file

@ -5,14 +5,14 @@ use std::{
time::{Duration, Instant},
};
use actix_web::dev::Service;
use anyhow::{anyhow, Error};
use cadence::{MetricSink, NopMetricSink, StatsdClient};
use futures_util::{
future::try_join_all,
stream::{self, BoxStream},
stream::{self, LocalBoxStream},
StreamExt as _,
};
use hyper::service::Service;
use once_cell::sync::Lazy;
use relative_path::{RelativePath, RelativePathBuf};
use rustsec::database::Database;
@ -38,7 +38,7 @@ mod machines;
use self::fut::{analyze_dependencies, crawl_manifest};
#[derive(Clone, Debug)]
#[derive(Debug, Clone)]
pub struct Engine {
metrics: Arc<StatsdClient>,
query_crate: Cache<QueryCrate, CrateName>,
@ -255,7 +255,10 @@ impl Engine {
Ok(latest)
}
fn fetch_releases<'a, I>(&'a self, names: I) -> BoxStream<'a, anyhow::Result<Vec<CrateRelease>>>
fn fetch_releases<'a, I>(
&'a self,
names: I,
) -> LocalBoxStream<'a, anyhow::Result<Vec<CrateRelease>>>
where
I: IntoIterator<Item = CrateName>,
<I as IntoIterator>::IntoIter: Send + 'a,
@ -277,7 +280,7 @@ impl Engine {
) -> Result<String, Error> {
let manifest_path = path.join(RelativePath::new("Cargo.toml"));
let mut service = self.retrieve_file_at_path.clone();
let service = self.retrieve_file_at_path.clone();
service.call((repo_path.clone(), manifest_path)).await
}

View file

@ -1,19 +1,16 @@
use std::{
fmt, str,
task::{Context, Poll},
};
use std::{fmt, str};
use actix_web::dev::Service;
use anyhow::{anyhow, Error};
use crates_index::{Crate, DependencyKind};
use futures_util::FutureExt as _;
use hyper::service::Service;
use futures_util::{future::LocalBoxFuture, FutureExt as _};
use semver::{Version, VersionReq};
use serde::Deserialize;
use tokio::task::spawn_blocking;
use crate::{
models::crates::{CrateDep, CrateDeps, CrateName, CratePath, CrateRelease},
BoxFuture, ManagedIndex,
ManagedIndex,
};
const CRATES_API_BASE_URI: &str = "https://crates.io/api/v1";
@ -86,13 +83,11 @@ impl fmt::Debug for QueryCrate {
impl Service<CrateName> for QueryCrate {
type Response = QueryCrateResponse;
type Error = Error;
type Future = BoxFuture<Result<Self::Response, Self::Error>>;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
actix_web::dev::always_ready!();
fn call(&mut self, crate_name: CrateName) -> Self::Future {
fn call(&self, crate_name: CrateName) -> Self::Future {
let index = self.index.clone();
Self::query(index, crate_name).boxed()
}
@ -150,13 +145,11 @@ impl fmt::Debug for GetPopularCrates {
impl Service<()> for GetPopularCrates {
type Response = Vec<CratePath>;
type Error = Error;
type Future = BoxFuture<Result<Self::Response, Self::Error>>;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
actix_web::dev::always_ready!();
fn call(&mut self, _req: ()) -> Self::Future {
fn call(&self, _req: ()) -> Self::Future {
let client = self.client.clone();
Self::query(client).boxed()
}

View file

@ -1,17 +1,11 @@
use std::{
fmt,
task::{Context, Poll},
};
use std::fmt;
use actix_web::dev::Service;
use anyhow::Error;
use futures_util::FutureExt as _;
use hyper::service::Service;
use futures_util::{future::LocalBoxFuture, FutureExt as _};
use serde::Deserialize;
use crate::{
models::repo::{RepoPath, Repository},
BoxFuture,
};
use crate::models::repo::{RepoPath, Repository};
const GITHUB_API_BASE_URI: &str = "https://api.github.com";
@ -72,13 +66,11 @@ impl fmt::Debug for GetPopularRepos {
impl Service<()> for GetPopularRepos {
type Response = Vec<Repository>;
type Error = Error;
type Future = BoxFuture<Result<Self::Response, Self::Error>>;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
actix_web::dev::always_ready!();
fn call(&mut self, _req: ()) -> Self::Future {
fn call(&self, _req: ()) -> Self::Future {
let client = self.client.clone();
Self::query(client).boxed()
}

View file

@ -1,14 +1,11 @@
use std::{
fmt,
task::{Context, Poll},
};
use std::fmt;
use actix_web::dev::Service;
use anyhow::{anyhow, Error};
use futures_util::FutureExt as _;
use hyper::service::Service;
use futures_util::{future::LocalBoxFuture, FutureExt as _};
use relative_path::RelativePathBuf;
use crate::{models::repo::RepoPath, BoxFuture};
use crate::models::repo::RepoPath;
pub mod crates;
pub mod github;
@ -43,13 +40,11 @@ impl RetrieveFileAtPath {
impl Service<(RepoPath, RelativePathBuf)> for RetrieveFileAtPath {
type Response = String;
type Error = Error;
type Future = BoxFuture<Result<Self::Response, Self::Error>>;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
actix_web::dev::always_ready!();
fn call(&mut self, (repo_path, path): (RepoPath, RelativePathBuf)) -> Self::Future {
fn call(&self, (repo_path, path): (RepoPath, RelativePathBuf)) -> Self::Future {
let client = self.client.clone();
Self::query(client, repo_path, path).boxed()
}

View file

@ -1,16 +1,10 @@
use std::{
fmt,
sync::Arc,
task::{Context, Poll},
};
use std::{fmt, sync::Arc};
use actix_web::dev::Service;
use anyhow::Error;
use futures_util::FutureExt as _;
use hyper::service::Service;
use futures_util::{future::LocalBoxFuture, FutureExt as _};
use rustsec::database::Database;
use crate::BoxFuture;
#[derive(Clone)]
pub struct FetchAdvisoryDatabase {
client: reqwest::Client,
@ -30,20 +24,19 @@ impl FetchAdvisoryDatabase {
impl Service<()> for FetchAdvisoryDatabase {
type Response = Arc<Database>;
type Error = Error;
type Future = BoxFuture<Result<Self::Response, Self::Error>>;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
actix_web::dev::always_ready!();
fn call(&mut self, _req: ()) -> Self::Future {
fn call(&self, _req: ()) -> Self::Future {
let client = self.client.clone();
Self::fetch(client).boxed()
Self::fetch(client).boxed_local()
}
}
impl fmt::Debug for FetchAdvisoryDatabase {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("FetchAdvisoryDatabase")
f.debug_struct("FetchAdvisoryDatabase")
.finish_non_exhaustive()
}
}

View file

@ -3,20 +3,14 @@
use std::{
env,
future::Future,
net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket},
pin::Pin,
net::{Ipv4Addr, UdpSocket},
time::Duration,
};
use actix_web::{middleware::Logger, web};
use actix_web_lab::{extract::ThinData, middleware::NormalizePath};
use cadence::{QueuingMetricSink, UdpMetricSink};
use hyper::{
server::conn::AddrStream,
service::{make_service_fn, service_fn},
Server,
};
use reqwest::redirect::Policy as RedirectPolicy;
use tracing::Instrument as _;
mod engine;
mod interactors;
@ -25,10 +19,7 @@ mod parsers;
mod server;
mod utils;
use self::{engine::Engine, server::App, utils::index::ManagedIndex};
/// Future crate's BoxFuture without the explicit lifetime parameter.
pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
use self::{engine::Engine, utils::index::ManagedIndex};
const DEPS_RS_UA: &str = "deps.rs";
@ -59,7 +50,7 @@ fn init_tracing_subscriber() {
.init();
}
#[tokio::main]
#[tokio::main(flavor = "current_thread")]
async fn main() {
dotenvy::dotenv().ok();
init_tracing_subscriber();
@ -77,8 +68,6 @@ async fn main() {
.parse()
.expect("could not read port");
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port);
let index = ManagedIndex::new();
{
@ -92,25 +81,24 @@ async fn main() {
let mut engine = Engine::new(client.clone(), index);
engine.set_metrics(metrics);
let make_svc = make_service_fn(move |_socket: &AddrStream| {
let engine = engine.clone();
async move {
let server = App::new(engine.clone());
Ok::<_, hyper::Error>(service_fn(move |req| {
let server = server.clone();
async move {
let path = req.uri().path().to_owned();
server
.handle(req)
.instrument(tracing::info_span!("@", %path))
.await
}
}))
}
});
let server = Server::bind(&addr).serve(make_svc);
let server = actix_web::HttpServer::new(move || {
actix_web::App::new()
.app_data(ThinData(engine.clone()))
.service(server::index)
.service(server::crate_redirect)
.service(server::crate_latest_status_svg)
.service(server::crate_status_svg)
.service(server::crate_status_html)
.service(server::repo_status_svg)
.service(server::repo_status_html)
.configure(server::static_files)
.default_service(web::to(server::not_found))
.wrap(NormalizePath::trim())
.wrap(Logger::default())
})
.bind_auto_h2c((Ipv4Addr::UNSPECIFIED, port))
.unwrap()
.run();
tracing::info!("Server running on port {port}");

View file

@ -100,7 +100,7 @@ impl FromStr for RepoSite {
if let Some((site, domain)) = input.split_once('/') {
match site {
"gitea" => Ok(RepoSite::Gitea(domain.parse()?)),
_ => Err(anyhow!("unknown repo site identifier")),
site => Err(anyhow!("unknown repo site identifier: {site}")),
}
} else {
match input {
@ -109,7 +109,7 @@ impl FromStr for RepoSite {
"bitbucket" => Ok(RepoSite::Bitbucket),
"sourcehut" => Ok(RepoSite::Sourcehut),
"codeberg" => Ok(RepoSite::Codeberg),
_ => Err(anyhow!("unknown repo site identifier")),
site => Err(anyhow!("unknown repo site identifier: {site}")),
}
}
}

View file

@ -4,11 +4,9 @@ pub const STATIC_STYLE_CSS_PATH: &str = concat!(
include_str!(concat!(env!("OUT_DIR"), "/style.css.sha1")),
".css"
);
pub const STATIC_STYLE_CSS_ETAG: &str = concat!(
"\"",
include_str!(concat!(env!("OUT_DIR"), "/style.css.sha1")),
"\""
);
pub const STATIC_STYLE_CSS_ETAG: &str = include_str!(concat!(env!("OUT_DIR"), "/style.css.sha1"));
pub const STATIC_FAVICON_PATH: &str = "/static/logo.svg";
pub static STATIC_FAVICON: &[u8] = include_bytes!("../../assets/logo.svg");
pub static STATIC_LINKS_JS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/links.js"));
@ -17,8 +15,4 @@ pub const STATIC_LINKS_JS_PATH: &str = concat!(
include_str!(concat!(env!("OUT_DIR"), "/links.js.sha1")),
".js"
);
pub const STATIC_LINKS_JS_ETAG: &str = concat!(
"\"",
include_str!(concat!(env!("OUT_DIR"), "/links.js.sha1")),
"\""
);
pub const STATIC_LINKS_JS_ETAG: &str = include_str!(concat!(env!("OUT_DIR"), "/links.js.sha1"));

79
src/server/error.rs Normal file
View file

@ -0,0 +1,79 @@
use actix_web::{
http::{header::ContentType, StatusCode},
HttpResponse, ResponseError,
};
use derive_more::Display;
use maud::Markup;
use crate::server::views::html::error::{render, render_404};
#[derive(Debug, Display)]
pub(crate) enum ServerError {
#[display("Could not retrieve popular items")]
PopularItemsFailed,
#[display("Crate not found")]
CrateNotFound,
#[display("Could not parse crate path")]
BadCratePath,
#[display("Could not fetch crate information")]
CrateFetchFailed,
#[display("Could not parse repository path")]
BadRepoPath,
#[display("Crate/repo analysis failed")]
AnalysisFailed(Markup),
}
impl ResponseError for ServerError {
fn status_code(&self) -> StatusCode {
match self {
ServerError::PopularItemsFailed => StatusCode::INTERNAL_SERVER_ERROR,
ServerError::CrateNotFound => StatusCode::NOT_FOUND,
ServerError::BadCratePath => StatusCode::BAD_REQUEST,
ServerError::CrateFetchFailed => StatusCode::NOT_FOUND,
ServerError::BadRepoPath => StatusCode::BAD_REQUEST,
ServerError::AnalysisFailed(_) => StatusCode::BAD_REQUEST,
}
}
fn error_response(&self) -> HttpResponse {
let mut res = HttpResponse::build(self.status_code());
let res = res.insert_header(ContentType::html());
match self {
ServerError::PopularItemsFailed => res.body(render(self.to_string(), "").0),
ServerError::CrateNotFound => res.body(render_404().0),
ServerError::BadCratePath => res.body(
render(
self.to_string(),
"Please make sure to provide a valid crate name and version.",
)
.0,
),
ServerError::CrateFetchFailed => res.body(
render(
self.to_string(),
"Please make sure to provide a valid crate name.",
)
.0,
),
ServerError::BadRepoPath => res.body(
render(
self.to_string(),
"Please make sure to provide a valid repository path.",
)
.0,
),
Self::AnalysisFailed(html) => res.body(html.0.clone()),
}
}
}

View file

@ -1,21 +1,35 @@
use std::{env, sync::Arc, time::Instant};
use std::env;
use actix_web::{
get,
http::{
header::{ContentType, ETag, EntityTag},
Uri,
},
web::{Redirect, ServiceConfig},
Either, HttpResponse, Resource, Responder,
};
use actix_web_lab::{
extract::{Path, ThinData},
header::{CacheControl, CacheDirective},
respond::Html,
};
use assets::STATIC_FAVICON_PATH;
use badge::BadgeStyle;
use futures_util::future;
use hyper::{
header::{CACHE_CONTROL, CONTENT_TYPE, ETAG, LOCATION},
Body, Error as HyperError, Method, Request, Response, StatusCode,
};
use once_cell::sync::Lazy;
use route_recognizer::{Params, Router};
use semver::VersionReq;
use serde::Deserialize;
mod assets;
mod error;
mod views;
use self::assets::{
STATIC_LINKS_JS_ETAG, STATIC_LINKS_JS_PATH, STATIC_STYLE_CSS_ETAG, STATIC_STYLE_CSS_PATH,
use self::{
assets::{
STATIC_LINKS_JS_ETAG, STATIC_LINKS_JS_PATH, STATIC_STYLE_CSS_ETAG, STATIC_STYLE_CSS_PATH,
},
error::ServerError,
};
use crate::{
engine::{AnalyzeDependenciesOutcome, Engine},
@ -24,400 +38,271 @@ use crate::{
repo::RepoPath,
SubjectPath,
},
utils::common::{safe_truncate, UntaggedEither, WrappedBool},
};
const MAX_SUBJECT_WIDTH: usize = 100;
#[derive(Debug, Clone, Copy, PartialEq)]
enum StatusFormat {
Html,
Svg,
}
#[derive(Debug, Clone, Copy)]
enum StaticFile {
StyleCss,
FaviconPng,
LinksJs,
#[get("/")]
pub(crate) async fn index(ThinData(engine): ThinData<Engine>) -> actix_web::Result<impl Responder> {
let popular = future::try_join(engine.get_popular_repos(), engine.get_popular_crates()).await;
match popular {
Err(err) => {
tracing::error!(%err);
Err(ServerError::PopularItemsFailed.into())
}
Ok((popular_repos, popular_crates)) => Ok(Html::new(
views::html::index::render(popular_repos, popular_crates).0,
)),
}
}
enum Route {
Index,
Static(StaticFile),
RepoStatus(StatusFormat),
CrateRedirect,
CrateStatus(StatusFormat),
LatestCrateBadge,
#[get("/repo/{site:.+?}/{qual}/{name}/status.svg")]
pub(crate) async fn repo_status_svg(
ThinData(engine): ThinData<Engine>,
uri: Uri,
Path(params): Path<(String, String, String)>,
) -> actix_web::Result<impl Responder> {
repo_status(engine, uri, params, StatusFormat::Svg).await
}
#[derive(Clone)]
pub struct App {
#[get("/repo/{site:.+?}/{qual}/{name}")]
pub(crate) async fn repo_status_html(
ThinData(engine): ThinData<Engine>,
uri: Uri,
Path(params): Path<(String, String, String)>,
) -> actix_web::Result<impl Responder> {
repo_status(engine, uri, params, StatusFormat::Html).await
}
async fn repo_status(
engine: Engine,
router: Arc<Router<Route>>,
}
uri: Uri,
(site, qual, name): (String, String, String),
format: StatusFormat,
) -> actix_web::Result<impl Responder> {
let extra_knobs = ExtraConfig::from_query_string(uri.query());
impl App {
pub fn new(engine: Engine) -> App {
let mut router = Router::new();
let repo_path_result = RepoPath::from_parts(&site, &qual, &name);
router.add("/", Route::Index);
router.add(STATIC_STYLE_CSS_PATH, Route::Static(StaticFile::StyleCss));
router.add("/static/logo.svg", Route::Static(StaticFile::FaviconPng));
router.add(STATIC_LINKS_JS_PATH, Route::Static(StaticFile::LinksJs));
router.add(
"/repo/*site/:qual/:name",
Route::RepoStatus(StatusFormat::Html),
);
router.add(
"/repo/*site/:qual/:name/status.svg",
Route::RepoStatus(StatusFormat::Svg),
);
router.add("/crate/:name", Route::CrateRedirect);
router.add(
"/crate/:name/:version",
Route::CrateStatus(StatusFormat::Html),
);
router.add("/crate/:name/latest/status.svg", Route::LatestCrateBadge);
router.add(
"/crate/:name/:version/status.svg",
Route::CrateStatus(StatusFormat::Svg),
);
App {
engine,
router: Arc::new(router),
let repo_path = match repo_path_result {
Ok(repo_path) => repo_path,
Err(err) => {
tracing::error!(%err);
return Err(ServerError::BadRepoPath.into());
}
}
};
pub async fn handle(&self, req: Request<Body>) -> Result<Response<Body>, HyperError> {
let start = Instant::now();
let analyze_result = engine
.analyze_repo_dependencies(repo_path.clone(), &extra_knobs.path)
.await;
// allows `/path/` to also match `/path`
let normalized_path = req.uri().path().trim_end_matches('/');
match analyze_result {
Err(err) => {
tracing::error!(%err);
let response =
status_format_analysis(None, format, SubjectPath::Repo(repo_path), extra_knobs);
let res = if let Ok(route_match) = self.router.recognize(normalized_path) {
match (req.method(), route_match.handler()) {
(&Method::GET, Route::Index) => self.index(req, route_match.params().clone()).await,
(&Method::GET, Route::RepoStatus(format)) => {
self.repo_status(req, route_match.params().clone(), *format)
.await
}
(&Method::GET, Route::CrateStatus(format)) => {
self.crate_status(req, route_match.params().clone(), *format)
.await
}
(&Method::GET, Route::LatestCrateBadge) => {
self.crate_status(req, route_match.params().clone(), StatusFormat::Svg)
.await
}
(&Method::GET, Route::CrateRedirect) => {
self.crate_redirect(req, route_match.params().clone()).await
}
(&Method::GET, Route::Static(file)) => Ok(App::static_file(*file)),
_ => Ok(not_found()),
}
} else {
Ok(not_found())
};
let end = Instant::now();
let diff = end - start;
match &res {
Ok(res) => tracing::info!(
status = %res.status(),
time = %format_args!("{}ms", diff.as_millis()),
),
Err(err) => tracing::error!(%err),
};
res
}
}
impl App {
async fn index(
&self,
_req: Request<Body>,
_params: Params,
) -> Result<Response<Body>, HyperError> {
let engine = self.engine.clone();
let popular =
future::try_join(engine.get_popular_repos(), engine.get_popular_crates()).await;
match popular {
Err(err) => {
tracing::error!(%err);
let mut response =
views::html::error::render("Could not retrieve popular items", "");
*response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
Ok(response)
}
Ok((popular_repos, popular_crates)) => {
Ok(views::html::index::render(popular_repos, popular_crates))
}
Ok(response)
}
}
async fn repo_status(
&self,
req: Request<Body>,
params: Params,
format: StatusFormat,
) -> Result<Response<Body>, HyperError> {
let server = self.clone();
Ok(analysis_outcome) => {
let response = status_format_analysis(
Some(analysis_outcome),
format,
SubjectPath::Repo(repo_path),
extra_knobs,
);
let site = params.find("site").expect("route param 'site' not found");
let qual = params.find("qual").expect("route param 'qual' not found");
let name = params.find("name").expect("route param 'name' not found");
let extra_knobs = ExtraConfig::from_query_string(req.uri().query());
let repo_path_result = RepoPath::from_parts(site, qual, name);
match repo_path_result {
Err(err) => {
tracing::error!(%err);
let mut response = views::html::error::render(
"Could not parse repository path",
"Please make sure to provide a valid repository path.",
);
*response.status_mut() = StatusCode::BAD_REQUEST;
Ok(response)
}
Ok(repo_path) => {
let analyze_result = server
.engine
.analyze_repo_dependencies(repo_path.clone(), &extra_knobs.path)
.await;
match analyze_result {
Err(err) => {
tracing::error!(%err);
let response = App::status_format_analysis(
None,
format,
SubjectPath::Repo(repo_path),
extra_knobs,
);
Ok(response)
}
Ok(analysis_outcome) => {
let response = App::status_format_analysis(
Some(analysis_outcome),
format,
SubjectPath::Repo(repo_path),
extra_knobs,
);
Ok(response)
}
}
}
}
}
async fn crate_redirect(
&self,
_req: Request<Body>,
params: Params,
) -> Result<Response<Body>, HyperError> {
let engine = self.engine.clone();
let name = params.find("name").expect("route param 'name' not found");
let crate_name_result = name.parse::<CrateName>();
match crate_name_result {
Err(err) => {
tracing::error!(%err);
let mut response = views::html::error::render(
"Could not parse crate name",
"Please make sure to provide a valid crate name.",
);
*response.status_mut() = StatusCode::BAD_REQUEST;
Ok(response)
}
Ok(crate_name) => {
let release_result = engine
.find_latest_stable_crate_release(crate_name, VersionReq::STAR)
.await;
match release_result {
Err(err) => {
tracing::error!(%err);
let mut response = views::html::error::render(
"Could not fetch crate information",
"Please make sure to provide a valid crate name.",
);
*response.status_mut() = StatusCode::NOT_FOUND;
Ok(response)
}
Ok(None) => {
let mut response = views::html::error::render(
"Could not fetch crate information",
"Please make sure to provide a valid crate name.",
);
*response.status_mut() = StatusCode::NOT_FOUND;
Ok(response)
}
Ok(Some(release)) => {
let redirect_url = format!(
"{}/crate/{}/{}",
&SELF_BASE_URL as &str,
release.name.as_ref(),
release.version
);
let res = Response::builder()
.status(StatusCode::TEMPORARY_REDIRECT)
.header(LOCATION, redirect_url)
.body(Body::empty())
.unwrap();
Ok(res)
}
}
}
}
}
async fn crate_status(
&self,
req: Request<Body>,
params: Params,
format: StatusFormat,
) -> Result<Response<Body>, HyperError> {
let server = self.clone();
let name = params.find("name").expect("route param 'name' not found");
let version = match params.find("version") {
Some(ver) => ver.to_owned(),
None => {
let crate_name = match name.parse() {
Ok(name) => name,
Err(_) => {
let mut response = views::html::error::render(
"Could not parse crate path",
"Please make sure to provide a valid crate name and version.",
);
*response.status_mut() = StatusCode::BAD_REQUEST;
return Ok(response);
}
};
match server
.engine
.find_latest_stable_crate_release(crate_name, VersionReq::STAR)
.await
{
Ok(Some(latest_rel)) => latest_rel.version.to_string(),
Ok(None) => return Ok(not_found()),
Err(err) => {
tracing::error!(%err);
let mut response = views::html::error::render(
"Could not fetch crate information",
"Please make sure to provide a valid crate name.",
);
*response.status_mut() = StatusCode::NOT_FOUND;
return Ok(response);
}
}
}
};
let crate_path_result = CratePath::from_parts(name, &version);
let badge_knobs = ExtraConfig::from_query_string(req.uri().query());
match crate_path_result {
Err(err) => {
tracing::error!(%err);
let mut response = views::html::error::render(
"Could not parse crate path",
"Please make sure to provide a valid crate name and version.",
);
*response.status_mut() = StatusCode::BAD_REQUEST;
Ok(response)
}
Ok(crate_path) => {
let analyze_result = server
.engine
.analyze_crate_dependencies(crate_path.clone())
.await;
match analyze_result {
Err(err) => {
tracing::error!(%err);
let response = App::status_format_analysis(
None,
format,
SubjectPath::Crate(crate_path),
badge_knobs,
);
Ok(response)
}
Ok(analysis_outcome) => {
let response = App::status_format_analysis(
Some(analysis_outcome),
format,
SubjectPath::Crate(crate_path),
badge_knobs,
);
Ok(response)
}
}
}
}
}
fn status_format_analysis(
analysis_outcome: Option<AnalyzeDependenciesOutcome>,
format: StatusFormat,
subject_path: SubjectPath,
badge_knobs: ExtraConfig,
) -> Response<Body> {
match format {
StatusFormat::Svg => views::badge::response(analysis_outcome.as_ref(), badge_knobs),
StatusFormat::Html => {
views::html::status::render(analysis_outcome, subject_path, badge_knobs)
}
}
}
fn static_file(file: StaticFile) -> Response<Body> {
match file {
StaticFile::StyleCss => Response::builder()
.header(CONTENT_TYPE, "text/css; charset=utf-8")
.header(ETAG, STATIC_STYLE_CSS_ETAG)
.header(CACHE_CONTROL, "public, max-age=365000000, immutable")
.body(Body::from(assets::STATIC_STYLE_CSS))
.unwrap(),
StaticFile::FaviconPng => Response::builder()
.header(CONTENT_TYPE, "image/svg+xml")
.body(Body::from(assets::STATIC_FAVICON))
.unwrap(),
StaticFile::LinksJs => Response::builder()
.header(CONTENT_TYPE, "text/javascript; charset=utf-8")
.header(ETAG, STATIC_LINKS_JS_ETAG)
.header(CACHE_CONTROL, "public, max-age=365000000, immutable")
.body(Body::from(assets::STATIC_LINKS_JS))
.unwrap(),
Ok(response)
}
}
}
fn not_found() -> Response<Body> {
views::html::error::render_404()
#[get("/crate/:name")]
async fn crate_redirect(
ThinData(engine): ThinData<Engine>,
Path((name,)): Path<(String,)>,
) -> actix_web::Result<impl Responder> {
let crate_name_result = name.parse::<CrateName>();
let crate_name = match crate_name_result {
Ok(crate_name) => crate_name,
Err(err) => {
tracing::error!(%err);
return Err(ServerError::BadCratePath.into());
}
};
let release_result = engine
.find_latest_stable_crate_release(crate_name, VersionReq::STAR)
.await
.inspect_err(|err| {
tracing::error!(%err);
});
let Ok(Some(release)) = release_result else {
return Err(ServerError::CrateFetchFailed.into());
};
let redirect_url = format!(
"{}/crate/{}/{}",
&SELF_BASE_URL as &str,
release.name.as_ref(),
release.version
);
Ok(Redirect::to(redirect_url))
}
#[get("/crate/{name}/{version}")]
async fn crate_status_html(
ThinData(engine): ThinData<Engine>,
uri: Uri,
Path((name, version)): Path<(String, String)>,
) -> actix_web::Result<impl Responder> {
crate_status(engine, uri, (name, Some(version)), StatusFormat::Html).await
}
#[get("/crate/{name}/latest/status.svg")]
async fn crate_latest_status_svg(
ThinData(engine): ThinData<Engine>,
uri: Uri,
Path((name,)): Path<(String,)>,
) -> actix_web::Result<impl Responder> {
crate_status(engine, uri, (name, None), StatusFormat::Svg).await
}
#[get("/crate/{name}/{version}/status.svg")]
async fn crate_status_svg(
ThinData(engine): ThinData<Engine>,
uri: Uri,
Path((name, version)): Path<(String, String)>,
) -> actix_web::Result<impl Responder> {
crate_status(engine, uri, (name, Some(version)), StatusFormat::Svg).await
}
async fn crate_status(
engine: Engine,
uri: Uri,
(name, version): (String, Option<String>),
format: StatusFormat,
) -> actix_web::Result<impl Responder> {
let version = match version {
Some(ver) => ver.to_owned(),
None => {
let crate_name = match name.parse() {
Ok(name) => name,
Err(_) => return Err(ServerError::BadCratePath.into()),
};
match engine
.find_latest_stable_crate_release(crate_name, VersionReq::STAR)
.await
{
Ok(Some(latest_rel)) => latest_rel.version.to_string(),
Ok(None) => return Err(ServerError::CrateNotFound.into()),
Err(err) => {
tracing::error!(%err);
return Err(ServerError::CrateFetchFailed.into());
}
}
}
};
let crate_path_result = CratePath::from_parts(&name, &version);
let badge_knobs = ExtraConfig::from_query_string(uri.query());
match crate_path_result {
Err(err) => {
tracing::error!(%err);
Err(ServerError::BadCratePath.into())
}
Ok(crate_path) => {
let analysis_outcome = engine
.analyze_crate_dependencies(crate_path.clone())
.await
.inspect_err(|err| {
tracing::error!(%err);
})
.ok();
let response = status_format_analysis(
analysis_outcome,
format,
SubjectPath::Crate(crate_path),
badge_knobs,
);
Ok(response)
}
}
}
fn status_format_analysis(
analysis_outcome: Option<AnalyzeDependenciesOutcome>,
format: StatusFormat,
subject_path: SubjectPath,
badge_knobs: ExtraConfig,
) -> impl Responder {
match format {
StatusFormat::Svg => Either::Left(views::badge::response(
analysis_outcome.as_ref(),
badge_knobs,
)),
StatusFormat::Html => Either::Right(views::html::status::response(
analysis_outcome,
subject_path,
badge_knobs,
)),
}
}
pub(crate) fn static_files(cfg: &mut ServiceConfig) {
cfg.service(Resource::new(STATIC_STYLE_CSS_PATH).get(|| async {
HttpResponse::Ok()
.insert_header(ContentType(mime::TEXT_CSS_UTF_8))
.insert_header(ETag(EntityTag::new_strong(
STATIC_STYLE_CSS_ETAG.to_owned(),
)))
.insert_header(CacheControl(vec![
CacheDirective::Public,
CacheDirective::MaxAge(365000000),
CacheDirective::Immutable,
]))
.body(assets::STATIC_STYLE_CSS)
}))
.service(Resource::new(STATIC_FAVICON_PATH).get(|| async {
HttpResponse::Ok()
.insert_header(ContentType(mime::IMAGE_SVG))
.body(assets::STATIC_FAVICON)
}))
.service(Resource::new(STATIC_LINKS_JS_PATH).get(|| async {
HttpResponse::Ok()
.insert_header(ContentType(mime::APPLICATION_JAVASCRIPT_UTF_8))
.insert_header(ETag(EntityTag::new_strong(STATIC_LINKS_JS_ETAG.to_owned())))
.insert_header(CacheControl(vec![
CacheDirective::Public,
CacheDirective::MaxAge(365000000),
CacheDirective::Immutable,
]))
.body(assets::STATIC_LINKS_JS)
}));
}
pub(crate) async fn not_found() -> impl Responder {
Html::new(views::html::error::render_404().0)
}
static SELF_BASE_URL: Lazy<String> =
@ -428,18 +313,35 @@ static SELF_BASE_URL: Lazy<String> =
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<String>,
/// Path in which the crate resides within the repository
path: Option<String>,
}
impl ExtraConfig {
fn from_query_string(qs: Option<&str>) -> Self {
/// This wrapper can make the deserialization process infallible.
#[derive(Debug, Clone, Deserialize)]
#[serde(transparent)]
struct QueryParam<T>(UntaggedEither<T, String>);
impl<T> QueryParam<T> {
fn opt(self) -> Option<T> {
either::Either::from(self.0).left()
}
}
#[derive(Debug, Clone, Default, Deserialize)]
struct ExtraConfigPartial {
style: Option<BadgeStyle>,
compact: Option<bool>,
style: Option<QueryParam<BadgeStyle>>,
compact: Option<QueryParam<WrappedBool>>,
subject: Option<String>,
path: Option<String>,
}
@ -448,9 +350,33 @@ impl ExtraConfig {
.unwrap_or_default();
Self {
style: extra_config.style.unwrap_or_default(),
compact: extra_config.compact.unwrap_or_default(),
style: extra_config
.style
.and_then(|qp| qp.opt())
.unwrap_or_default(),
compact: extra_config
.compact
.and_then(|qp| qp.opt())
.unwrap_or_default()
.0,
subject: extra_config
.subject
.filter(|t| !t.is_empty())
.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"
}
}
}

View file

@ -1,5 +1,5 @@
use actix_web::{http::header::ContentType, HttpResponse};
use badge::{Badge, BadgeOptions};
use hyper::{header::CONTENT_TYPE, Body, Response};
use crate::{engine::AnalyzeDependenciesOutcome, server::ExtraConfig};
@ -7,12 +7,7 @@ pub fn badge(
analysis_outcome: Option<&AnalyzeDependenciesOutcome>,
badge_knobs: ExtraConfig,
) -> Badge {
let subject = if badge_knobs.compact {
"deps"
} else {
"dependencies"
}
.to_owned();
let subject = badge_knobs.subject().to_owned();
let opts = match analysis_outcome {
Some(outcome) => {
@ -73,11 +68,10 @@ pub fn badge(
pub fn response(
analysis_outcome: Option<&AnalyzeDependenciesOutcome>,
badge_knobs: ExtraConfig,
) -> Response<Body> {
) -> HttpResponse {
let badge = badge(analysis_outcome, badge_knobs).to_svg();
Response::builder()
.header(CONTENT_TYPE, "image/svg+xml; charset=utf-8")
.body(Body::from(badge))
.unwrap()
HttpResponse::Ok()
.insert_header(ContentType(mime::IMAGE_SVG))
.body(badge)
}

View file

@ -1,14 +1,12 @@
use hyper::{
header::{CACHE_CONTROL, CONTENT_TYPE},
Body, Response, StatusCode,
};
use maud::html;
use maud::{html, Markup};
use crate::server::assets::STATIC_STYLE_CSS_PATH;
pub fn render(title: &str, descr: &str) -> Response<Body> {
pub fn render(title: impl Into<String>, desc: &str) -> Markup {
let title = title.into();
super::render_html(
title,
title.clone(),
html! {
section class="hero is-light" {
div class="hero-head" { (super::render_navbar()) }
@ -17,7 +15,7 @@ pub fn render(title: &str, descr: &str) -> Response<Body> {
div class="container" {
div class="notification is-danger" {
p class="title is-3" { (title) }
p { (descr) }
p { (desc) }
}
}
}
@ -26,8 +24,8 @@ pub fn render(title: &str, descr: &str) -> Response<Body> {
)
}
pub fn render_404() -> Response<Body> {
let rendered = html! {
pub fn render_404() -> Markup {
html! {
html {
head {
meta charset="utf-8";
@ -53,12 +51,5 @@ pub fn render_404() -> Response<Body> {
(super::render_footer(None))
}
}
};
Response::builder()
.status(StatusCode::NOT_FOUND)
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.header(CACHE_CONTROL, "public, max-age=300, immutable")
.body(Body::from(rendered.0))
.unwrap()
}
}

View file

@ -1,4 +1,3 @@
use hyper::{Body, Response};
use maud::{html, Markup};
use crate::{
@ -161,7 +160,7 @@ fn popular_table(popular_repos: Vec<Repository>, popular_crates: Vec<CratePath>)
}
}
pub fn render(popular_repos: Vec<Repository>, popular_crates: Vec<CratePath>) -> Response<Body> {
pub fn render(popular_repos: Vec<Repository>, popular_crates: Vec<CratePath>) -> Markup {
super::render_html(
"Keep your dependencies up-to-date",
html! {

View file

@ -1,6 +1,5 @@
use std::time::Duration;
use hyper::{header::CONTENT_TYPE, Body, Response};
use maud::{html, Markup, Render, DOCTYPE};
pub mod error;
@ -9,8 +8,10 @@ pub mod status;
use crate::server::{assets::STATIC_STYLE_CSS_PATH, SELF_BASE_URL};
fn render_html<B: Render>(title: &str, body: B) -> Response<Body> {
let rendered = html! {
fn render_html<B: Render>(title: impl Into<String>, body: B) -> Markup {
let title = title.into();
html! {
(DOCTYPE)
html {
head {
@ -24,12 +25,7 @@ fn render_html<B: Render>(title: &str, body: B) -> Response<Body> {
}
body { (body) }
}
};
Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(rendered.0))
.unwrap()
}
}
fn render_navbar() -> Markup {

View file

@ -1,5 +1,6 @@
use actix_web::Responder;
use actix_web_lab::respond::Html;
use font_awesome_as_a_crate::{svg as fa, Type as FaType};
use hyper::{Body, Response};
use indexmap::IndexMap;
use maud::{html, Markup, PreEscaped};
use pulldown_cmark::{html, Parser};
@ -13,9 +14,11 @@ use crate::{
repo::RepoSite,
SubjectPath,
},
server::{views::badge, ExtraConfig},
server::{error::ServerError, views::badge, ExtraConfig},
};
use super::render_html;
fn get_crates_url(name: impl AsRef<str>) -> String {
format!("https://crates.io/crates/{}", name.as_ref())
}
@ -453,11 +456,11 @@ fn render_success(
}
}
pub fn render(
pub fn response(
analysis_outcome: Option<AnalyzeDependenciesOutcome>,
subject_path: SubjectPath,
extra_config: ExtraConfig,
) -> Response<Body> {
) -> actix_web::Result<impl Responder> {
let title = match subject_path {
SubjectPath::Repo(ref repo_path) => {
format!("{} / {}", repo_path.qual.as_ref(), repo_path.name.as_ref())
@ -468,8 +471,12 @@ pub fn render(
};
if let Some(outcome) = analysis_outcome {
super::render_html(&title, render_success(outcome, subject_path, extra_config))
Ok(Html::new(render_html(
&title,
render_success(outcome, subject_path, extra_config),
)))
} else {
super::render_html(&title, render_failure(subject_path))
let html = render_html(&title, render_failure(subject_path));
Err(ServerError::AnalysisFailed(html).into())
}
}

View file

@ -1,7 +1,7 @@
use std::{fmt, sync::Arc, time::Duration};
use actix_web::dev::Service;
use derive_more::{Display, Error, From};
use hyper::service::Service;
use lru_time_cache::LruCache;
use tokio::sync::Mutex;
@ -65,7 +65,7 @@ where
cache = "miss",
);
let mut service = self.inner.clone();
let service = self.inner.clone();
let fresh = service.call(req.clone()).await?;
{

150
src/utils/common.rs Normal file
View file

@ -0,0 +1,150 @@
use either::Either;
use serde::{Deserialize, Serialize};
use serde_with::{DeserializeFromStr, SerializeDisplay};
use std::{
fmt::{self, Debug, Display, Formatter},
str::FromStr,
};
/// An `untagged` version of `Either`.
///
/// The reason this structure is needed is that `either::Either` is
/// by default an `Externally Tagged` enum, and it is possible to
/// implement `untagged` via `#[serde(with = "either::serde_untagged_optional")]`
/// as well. But this approach can cause problems with deserialization,
/// resulting in having to manually add the `#[serde(default)]` tag,
/// and this leads to less readable as well as less flexible code.
/// So it would be better if we manually implement this `UntaggedEither` here,
/// while providing a two-way conversion to `either::Either`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum UntaggedEither<L, R> {
Left(L),
Right(R),
}
impl<L, R> From<UntaggedEither<L, R>> for Either<L, R> {
fn from(value: UntaggedEither<L, R>) -> Self {
match value {
UntaggedEither::Left(l) => Self::Left(l),
UntaggedEither::Right(r) => Self::Right(r),
}
}
}
impl<L, R> From<Either<L, R>> for UntaggedEither<L, R> {
fn from(value: Either<L, R>) -> Self {
match value {
Either::Left(l) => UntaggedEither::Left(l),
Either::Right(r) => UntaggedEither::Right(r),
}
}
}
/// A generic newtype which serialized using `Display` and deserialized using `FromStr`.
#[derive(Default, Clone, DeserializeFromStr, SerializeDisplay)]
pub struct SerdeDisplayFromStr<T>(pub T);
impl<T: Debug> Debug for SerdeDisplayFromStr<T> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
Debug::fmt(&self.0, f)
}
}
impl<T: Display> Display for SerdeDisplayFromStr<T> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
Display::fmt(&self.0, f)
}
}
impl<T: FromStr> FromStr for SerdeDisplayFromStr<T> {
type Err = T::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<T>().map(Self)
}
}
/// The reason it's needed here is that using `Deserialize` generated
/// by default by `serde` will cause deserialization to fail if
/// both untyped formats (such as `urlencoded`) and `untagged enum`
/// are used. The Wrap type here forces the deserialization process to
/// be delegated to `FromStr`.
pub type WrappedBool = SerdeDisplayFromStr<bool>;
/// 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), "🦊");
}
}

View file

@ -1,2 +1,3 @@
pub mod cache;
pub mod common;
pub mod index;