use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use url::Url;
pub const DEFAULT_PORT: u16 = 79;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ClientError {
BadUrl(String),
Connect(String),
Io(String),
}
impl std::fmt::Display for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BadUrl(m) => write!(f, "bad url: {m}"),
Self::Connect(m) => write!(f, "connect: {m}"),
Self::Io(m) => write!(f, "io: {m}"),
}
}
}
impl std::error::Error for ClientError {}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Query {
pub user: Option<String>,
pub verbose: bool,
}
impl Query {
pub fn user(name: impl Into<String>) -> Self {
Self {
user: Some(name.into()),
verbose: false,
}
}
pub fn verbose(mut self) -> Self {
self.verbose = true;
self
}
pub fn wire(&self) -> String {
let user = self.user.as_deref().unwrap_or("");
match (self.verbose, user.is_empty()) {
(true, true) => "/W\r\n".to_string(),
(true, false) => format!("/W {user}\r\n"),
(false, _) => format!("{user}\r\n"),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Response {
pub body: Vec<u8>,
}
impl Response {
pub fn text(&self) -> std::borrow::Cow<'_, str> {
String::from_utf8_lossy(&self.body)
}
}
pub async fn fetch(url: &str) -> Result<Response, ClientError> {
let url = Url::parse(url).map_err(|e| ClientError::BadUrl(e.to_string()))?;
let host = url
.host_str()
.ok_or_else(|| ClientError::BadUrl("finger URL has no host".into()))?;
let port = url.port().unwrap_or(DEFAULT_PORT);
query(host, port, &query_from_url(&url)).await
}
pub async fn query(host: &str, port: u16, request: &Query) -> Result<Response, ClientError> {
let mut stream = TcpStream::connect((host, port))
.await
.map_err(|e| ClientError::Connect(format!("tcp {host}:{port}: {e}")))?;
stream
.write_all(request.wire().as_bytes())
.await
.map_err(|e| ClientError::Io(e.to_string()))?;
let mut body = Vec::new();
stream
.read_to_end(&mut body)
.await
.map_err(|e| ClientError::Io(e.to_string()))?;
Ok(Response { body })
}
fn query_from_url(url: &Url) -> Query {
let from_path = url.path().trim_start_matches('/');
let user = if !from_path.is_empty() {
Some(from_path.to_string())
} else if !url.username().is_empty() {
Some(url.username().to_string())
} else {
None
};
Query {
user,
verbose: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn target(u: &str) -> Option<String> {
query_from_url(&Url::parse(u).unwrap()).user
}
#[test]
fn user_from_path_or_userinfo_or_listing() {
assert_eq!(target("finger://example.org/alice").as_deref(), Some("alice"));
assert_eq!(target("finger://bob@example.org").as_deref(), Some("bob"));
assert_eq!(target("finger://example.org/"), None);
}
#[test]
fn the_verbose_switch_rides_before_the_user() {
assert_eq!(Query::user("alice").verbose().wire(), "/W alice\r\n");
assert_eq!(Query::default().verbose().wire(), "/W\r\n");
}
#[tokio::test]
async fn a_url_without_a_host_is_refused_before_connecting() {
let error = fetch("finger:///alice").await.unwrap_err();
assert!(matches!(error, ClientError::BadUrl(_)), "got {error:?}");
}
}