Skip to main content

digit/
query.rs

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