digit-cli 0.2.0

A finger protocol client (RFC 1288 / RFC 742)
Documentation
/// A parsed finger query.
///
/// Represents the structured result of parsing a finger query string
/// like `"user@host"`, `"@host"`, or `"user@host1@host2"`.
#[derive(Debug, Clone, PartialEq)]
pub struct Query {
    /// The user to query. `None` means list all users.
    pub user: Option<String>,
    /// The host(s) to query. The last host is the connection target.
    /// Multiple hosts indicate a forwarding chain (RFC 1288).
    pub hosts: Vec<String>,
    /// Whether to request verbose output (sends `/W` prefix).
    pub long: bool,
    /// TCP port to connect on. Default is 79.
    pub port: u16,
}

/// The default finger protocol port.
pub const DEFAULT_PORT: u16 = 79;

/// Errors that can occur when parsing a finger query string.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum QueryError {
    /// One or more hostname segments in the query are empty.
    #[error("invalid query: empty hostname in '{input}'")]
    EmptyHostname { input: String },
}

impl Query {
    /// Parse a query string into a `Query`.
    ///
    /// # Examples
    ///
    /// - `"user@host"` -> user=Some("user"), hosts=["host"]
    /// - `"@host"` -> user=None, hosts=["host"]
    /// - `"user@host1@host2"` -> user=Some("user"), hosts=["host1", "host2"]
    /// - `"user"` -> user=Some("user"), hosts=["localhost"]
    /// - `""` or `None` -> user=None, hosts=["localhost"]
    pub fn parse(input: Option<&str>, long: bool, port: u16) -> Result<Query, QueryError> {
        let input = input.unwrap_or("");

        if input.is_empty() {
            return Ok(Query {
                user: None,
                hosts: vec!["localhost".to_string()],
                long,
                port,
            });
        }

        // Split on '@'. First part is the user (if non-empty), rest are hosts.
        let parts: Vec<&str> = input.splitn(2, '@').collect();

        if parts.len() == 1 {
            // No '@' found -- user only, default to localhost.
            return Ok(Query {
                user: Some(parts[0].to_string()),
                hosts: vec!["localhost".to_string()],
                long,
                port,
            });
        }

        // Has at least one '@'.
        let user = if parts[0].is_empty() {
            None
        } else {
            Some(parts[0].to_string())
        };

        let hosts: Vec<String> = parts[1].split('@').map(|s| s.to_string()).collect();

        // Validate: no empty hostnames.
        if hosts.iter().any(|h| h.is_empty()) {
            return Err(QueryError::EmptyHostname {
                input: input.to_string(),
            });
        }

        Ok(Query {
            user,
            hosts,
            long,
            port,
        })
    }

    /// Returns the host to connect to (the last host in the chain).
    pub fn target_host(&self) -> &str {
        self.hosts.last().expect("hosts must not be empty")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_user_at_host() {
        let q = Query::parse(Some("user@host"), false, 79).unwrap();
        assert_eq!(q.user, Some("user".to_string()));
        assert_eq!(q.hosts, vec!["host".to_string()]);
        assert!(!q.long);
        assert_eq!(q.port, 79);
    }

    #[test]
    fn parse_at_host_lists_users() {
        let q = Query::parse(Some("@host"), false, 79).unwrap();
        assert_eq!(q.user, None);
        assert_eq!(q.hosts, vec!["host".to_string()]);
    }

    #[test]
    fn parse_user_only_defaults_to_localhost() {
        let q = Query::parse(Some("user"), false, 79).unwrap();
        assert_eq!(q.user, Some("user".to_string()));
        assert_eq!(q.hosts, vec!["localhost".to_string()]);
    }

    #[test]
    fn parse_empty_string_defaults_to_localhost() {
        let q = Query::parse(Some(""), false, 79).unwrap();
        assert_eq!(q.user, None);
        assert_eq!(q.hosts, vec!["localhost".to_string()]);
    }

    #[test]
    fn parse_none_defaults_to_localhost() {
        let q = Query::parse(None, false, 79).unwrap();
        assert_eq!(q.user, None);
        assert_eq!(q.hosts, vec!["localhost".to_string()]);
    }

    #[test]
    fn parse_forwarding_chain() {
        let q = Query::parse(Some("user@host1@host2"), false, 79).unwrap();
        assert_eq!(q.user, Some("user".to_string()));
        assert_eq!(q.hosts, vec!["host1".to_string(), "host2".to_string()]);
    }

    #[test]
    fn parse_forwarding_chain_no_user() {
        let q = Query::parse(Some("@host1@host2"), false, 79).unwrap();
        assert_eq!(q.user, None);
        assert_eq!(q.hosts, vec!["host1".to_string(), "host2".to_string()]);
    }

    #[test]
    fn parse_long_flag_preserved() {
        let q = Query::parse(Some("user@host"), true, 79).unwrap();
        assert!(q.long);
    }

    #[test]
    fn parse_custom_port_preserved() {
        let q = Query::parse(Some("user@host"), false, 7979).unwrap();
        assert_eq!(q.port, 7979);
    }

    #[test]
    fn target_host_returns_last_host() {
        let q = Query::parse(Some("user@host1@host2"), false, 79).unwrap();
        assert_eq!(q.target_host(), "host2");
    }

    #[test]
    fn target_host_single_host() {
        let q = Query::parse(Some("user@host"), false, 79).unwrap();
        assert_eq!(q.target_host(), "host");
    }

    #[test]
    fn parse_three_host_chain() {
        let q = Query::parse(Some("user@a@b@c"), false, 79).unwrap();
        assert_eq!(q.user, Some("user".to_string()));
        assert_eq!(
            q.hosts,
            vec!["a".to_string(), "b".to_string(), "c".to_string()]
        );
        assert_eq!(q.target_host(), "c");
    }

    #[test]
    fn parse_trailing_at_is_error() {
        let result = Query::parse(Some("user@"), false, 79);
        assert_eq!(
            result,
            Err(QueryError::EmptyHostname {
                input: "user@".to_string()
            })
        );
    }

    #[test]
    fn parse_bare_at_is_error() {
        let result = Query::parse(Some("@"), false, 79);
        assert_eq!(
            result,
            Err(QueryError::EmptyHostname {
                input: "@".to_string()
            })
        );
    }

    #[test]
    fn parse_trailing_at_in_chain_is_error() {
        let result = Query::parse(Some("user@host@"), false, 79);
        assert_eq!(
            result,
            Err(QueryError::EmptyHostname {
                input: "user@host@".to_string()
            })
        );
    }

    #[test]
    fn parse_double_at_is_error() {
        let result = Query::parse(Some("user@@host"), false, 79);
        assert_eq!(
            result,
            Err(QueryError::EmptyHostname {
                input: "user@@host".to_string()
            })
        );
    }

    #[test]
    fn parse_at_host_trailing_at_is_error() {
        let result = Query::parse(Some("@host@"), false, 79);
        assert_eq!(
            result,
            Err(QueryError::EmptyHostname {
                input: "@host@".to_string()
            })
        );
    }
}