Skip to main content

digit/
query.rs

1//! Finger query string parsing.
2//!
3//! Parses user input (e.g. `"user@host"`, `"@host"`, `"user@host1@host2"`)
4//! into a structured [`Query`] for use with [`crate::protocol::finger`].
5
6/// A parsed finger query.
7///
8/// Represents the structured result of parsing a finger query string
9/// like `"user@host"`, `"@host"`, or `"user@host1@host2"`.
10#[derive(Debug, Clone, PartialEq)]
11pub struct Query {
12    /// The user to query. `None` means list all users.
13    pub user: Option<String>,
14    /// The host(s) to query. The last host is the connection target.
15    /// Multiple hosts indicate a forwarding chain (RFC 1288).
16    pub hosts: Vec<String>,
17    /// Whether to request verbose output (sends `/W` prefix).
18    pub long: bool,
19    /// TCP port to connect on. Default is 79.
20    pub port: u16,
21}
22
23/// The default finger protocol port.
24pub const DEFAULT_PORT: u16 = 79;
25
26/// Errors that can occur when parsing a finger query string.
27#[derive(Debug, Clone, PartialEq, thiserror::Error)]
28pub enum QueryError {
29    /// One or more hostname segments in the query are empty.
30    #[error("invalid query: empty hostname in '{input}'")]
31    EmptyHostname { input: String },
32}
33
34impl Query {
35    /// Parse a query string into a [`Query`].
36    ///
37    /// Returns an error if any hostname segment is empty (e.g. `"user@"`, `"user@@host"`).
38    ///
39    /// # Examples
40    ///
41    /// ```
42    /// use digit::query::Query;
43    ///
44    /// // Standard user query
45    /// let q = Query::parse(Some("user@example.com"), false, 79).unwrap();
46    /// assert_eq!(q.user, Some("user".to_string()));
47    /// assert_eq!(q.target_host(), "example.com");
48    ///
49    /// // Empty hostname is rejected
50    /// assert!(Query::parse(Some("user@"), false, 79).is_err());
51    /// ```
52    pub fn parse(input: Option<&str>, long: bool, port: u16) -> Result<Query, QueryError> {
53        let input = input.unwrap_or("");
54
55        if input.is_empty() {
56            return Ok(Query {
57                user: None,
58                hosts: vec!["localhost".to_string()],
59                long,
60                port,
61            });
62        }
63
64        // Split on '@'. First part is the user (if non-empty), rest are hosts.
65        let parts: Vec<&str> = input.splitn(2, '@').collect();
66
67        if parts.len() == 1 {
68            // No '@' found -- user only, default to localhost.
69            return Ok(Query {
70                user: Some(parts[0].to_string()),
71                hosts: vec!["localhost".to_string()],
72                long,
73                port,
74            });
75        }
76
77        // Has at least one '@'.
78        let user = if parts[0].is_empty() {
79            None
80        } else {
81            Some(parts[0].to_string())
82        };
83
84        let hosts: Vec<String> = parts[1].split('@').map(|s| s.to_string()).collect();
85
86        // Validate: no empty hostnames.
87        if hosts.iter().any(|h| h.is_empty()) {
88            return Err(QueryError::EmptyHostname {
89                input: input.to_string(),
90            });
91        }
92
93        Ok(Query {
94            user,
95            hosts,
96            long,
97            port,
98        })
99    }
100
101    /// Returns the host to connect to (the last host in the chain).
102    pub fn target_host(&self) -> &str {
103        self.hosts.last().expect("hosts must not be empty")
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn parse_user_at_host() {
113        let q = Query::parse(Some("user@host"), false, 79).unwrap();
114        assert_eq!(q.user, Some("user".to_string()));
115        assert_eq!(q.hosts, vec!["host".to_string()]);
116        assert!(!q.long);
117        assert_eq!(q.port, 79);
118    }
119
120    #[test]
121    fn parse_at_host_lists_users() {
122        let q = Query::parse(Some("@host"), false, 79).unwrap();
123        assert_eq!(q.user, None);
124        assert_eq!(q.hosts, vec!["host".to_string()]);
125    }
126
127    #[test]
128    fn parse_user_only_defaults_to_localhost() {
129        let q = Query::parse(Some("user"), false, 79).unwrap();
130        assert_eq!(q.user, Some("user".to_string()));
131        assert_eq!(q.hosts, vec!["localhost".to_string()]);
132    }
133
134    #[test]
135    fn parse_empty_string_defaults_to_localhost() {
136        let q = Query::parse(Some(""), false, 79).unwrap();
137        assert_eq!(q.user, None);
138        assert_eq!(q.hosts, vec!["localhost".to_string()]);
139    }
140
141    #[test]
142    fn parse_none_defaults_to_localhost() {
143        let q = Query::parse(None, false, 79).unwrap();
144        assert_eq!(q.user, None);
145        assert_eq!(q.hosts, vec!["localhost".to_string()]);
146    }
147
148    #[test]
149    fn parse_forwarding_chain() {
150        let q = Query::parse(Some("user@host1@host2"), false, 79).unwrap();
151        assert_eq!(q.user, Some("user".to_string()));
152        assert_eq!(q.hosts, vec!["host1".to_string(), "host2".to_string()]);
153    }
154
155    #[test]
156    fn parse_forwarding_chain_no_user() {
157        let q = Query::parse(Some("@host1@host2"), false, 79).unwrap();
158        assert_eq!(q.user, None);
159        assert_eq!(q.hosts, vec!["host1".to_string(), "host2".to_string()]);
160    }
161
162    #[test]
163    fn parse_long_flag_preserved() {
164        let q = Query::parse(Some("user@host"), true, 79).unwrap();
165        assert!(q.long);
166    }
167
168    #[test]
169    fn parse_custom_port_preserved() {
170        let q = Query::parse(Some("user@host"), false, 7979).unwrap();
171        assert_eq!(q.port, 7979);
172    }
173
174    #[test]
175    fn target_host_returns_last_host() {
176        let q = Query::parse(Some("user@host1@host2"), false, 79).unwrap();
177        assert_eq!(q.target_host(), "host2");
178    }
179
180    #[test]
181    fn target_host_single_host() {
182        let q = Query::parse(Some("user@host"), false, 79).unwrap();
183        assert_eq!(q.target_host(), "host");
184    }
185
186    #[test]
187    fn parse_three_host_chain() {
188        let q = Query::parse(Some("user@a@b@c"), false, 79).unwrap();
189        assert_eq!(q.user, Some("user".to_string()));
190        assert_eq!(
191            q.hosts,
192            vec!["a".to_string(), "b".to_string(), "c".to_string()]
193        );
194        assert_eq!(q.target_host(), "c");
195    }
196
197    #[test]
198    fn parse_trailing_at_is_error() {
199        let result = Query::parse(Some("user@"), false, 79);
200        assert_eq!(
201            result,
202            Err(QueryError::EmptyHostname {
203                input: "user@".to_string()
204            })
205        );
206    }
207
208    #[test]
209    fn parse_bare_at_is_error() {
210        let result = Query::parse(Some("@"), false, 79);
211        assert_eq!(
212            result,
213            Err(QueryError::EmptyHostname {
214                input: "@".to_string()
215            })
216        );
217    }
218
219    #[test]
220    fn parse_trailing_at_in_chain_is_error() {
221        let result = Query::parse(Some("user@host@"), false, 79);
222        assert_eq!(
223            result,
224            Err(QueryError::EmptyHostname {
225                input: "user@host@".to_string()
226            })
227        );
228    }
229
230    #[test]
231    fn parse_double_at_is_error() {
232        let result = Query::parse(Some("user@@host"), false, 79);
233        assert_eq!(
234            result,
235            Err(QueryError::EmptyHostname {
236                input: "user@@host".to_string()
237            })
238        );
239    }
240
241    #[test]
242    fn parse_at_host_trailing_at_is_error() {
243        let result = Query::parse(Some("@host@"), false, 79);
244        assert_eq!(
245            result,
246            Err(QueryError::EmptyHostname {
247                input: "@host@".to_string()
248            })
249        );
250    }
251}