finger_protocol/
client.rs1pub use crate::Query;
22
23use tokio::io::{AsyncReadExt, AsyncWriteExt};
24use tokio::net::TcpStream;
25use url::Url;
26
27pub const DEFAULT_PORT: u16 = 79;
29
30#[derive(Clone, Debug, PartialEq, Eq)]
32pub enum ClientError {
33 BadUrl(String),
35 Connect(String),
37 Io(String),
39}
40
41impl std::fmt::Display for ClientError {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 match self {
44 Self::BadUrl(m) => write!(f, "bad url: {m}"),
45 Self::Connect(m) => write!(f, "connect: {m}"),
46 Self::Io(m) => write!(f, "io: {m}"),
47 }
48 }
49}
50
51impl std::error::Error for ClientError {}
52
53#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct Response {
59 pub body: Vec<u8>,
60}
61
62impl Response {
63 pub fn text(&self) -> std::borrow::Cow<'_, str> {
65 String::from_utf8_lossy(&self.body)
66 }
67}
68
69pub async fn fetch(url: &str) -> Result<Response, ClientError> {
74 let url = Url::parse(url).map_err(|e| ClientError::BadUrl(e.to_string()))?;
75 let host = url
76 .host_str()
77 .ok_or_else(|| ClientError::BadUrl("finger URL has no host".into()))?;
78 let port = url.port().unwrap_or(DEFAULT_PORT);
79 query(host, port, &query_from_url(&url)).await
80}
81
82pub async fn query(host: &str, port: u16, request: &Query) -> Result<Response, ClientError> {
84 let mut stream = TcpStream::connect((host, port))
85 .await
86 .map_err(|e| ClientError::Connect(format!("tcp {host}:{port}: {e}")))?;
87 stream
88 .write_all(request.wire().as_bytes())
89 .await
90 .map_err(|e| ClientError::Io(e.to_string()))?;
91 let mut body = Vec::new();
92 stream
93 .read_to_end(&mut body)
94 .await
95 .map_err(|e| ClientError::Io(e.to_string()))?;
96 Ok(Response { body })
97}
98
99fn query_from_url(url: &Url) -> Query {
102 let from_path = url.path().trim_start_matches('/');
103 let user = if !from_path.is_empty() {
104 Some(from_path.to_string())
105 } else if !url.username().is_empty() {
106 Some(url.username().to_string())
107 } else {
108 None
109 };
110 Query {
111 user,
112 verbose: false,
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 fn target(u: &str) -> Option<String> {
121 query_from_url(&Url::parse(u).unwrap()).user
122 }
123
124 #[test]
125 fn user_from_path_or_userinfo_or_listing() {
126 assert_eq!(target("finger://example.org/alice").as_deref(), Some("alice"));
127 assert_eq!(target("finger://bob@example.org").as_deref(), Some("bob"));
128 assert_eq!(target("finger://example.org/"), None);
129 }
130
131 #[test]
132 fn the_verbose_switch_rides_before_the_user() {
133 assert_eq!(Query::user("alice").verbose().wire(), "/W alice\r\n");
134 assert_eq!(Query::default().verbose().wire(), "/W\r\n");
135 }
136
137 #[tokio::test]
138 async fn a_url_without_a_host_is_refused_before_connecting() {
139 let error = fetch("finger:///alice").await.unwrap_err();
140 assert!(matches!(error, ClientError::BadUrl(_)), "got {error:?}");
141 }
142}