//! Unix socket protocol for daemon ↔ CLI. //! //! Simple line-oriented text protocol: //! SHORTEN \n //! RESOLVE \n //! DEL \n //! LIST\n //! STATUS\n //! SHUTDOWN\n //! //! Responses: //! OK \n //! ERR \n /// A parsed request received from the CLI over the Unix socket. pub enum Request { Shorten { ttl_secs: u64, slug: String, target_url: String, }, Resolve { slug: String, }, Del { slug: String, }, List, Status, Shutdown, } /// A response sent back to the CLI over the Unix socket. pub enum Response { Ok(String), Err(String), } /// Parse a single protocol line into a [`Request`]. pub fn parse_request(line: &str) -> Result { let line = line.trim(); if line.is_empty() { return Err("empty request".into()); } let mut parts = line.splitn(4, ' '); let cmd = parts.next().unwrap(); match cmd { "SHORTEN" => { let ttl_str = parts .next() .ok_or("SHORTEN requires: SHORTEN ")?; let slug = parts .next() .ok_or("SHORTEN requires: SHORTEN ")?; let target_url = parts .next() .ok_or("SHORTEN requires: SHORTEN ")?; let ttl_secs: u64 = ttl_str.parse().map_err(|_| "invalid TTL number")?; Ok(Request::Shorten { ttl_secs, slug: slug.to_string(), target_url: target_url.to_string(), }) } "RESOLVE" => { let slug = parts.next().ok_or("RESOLVE requires: RESOLVE ")?; Ok(Request::Resolve { slug: slug.to_string(), }) } "DEL" => { let slug = parts.next().ok_or("DEL requires: DEL ")?; Ok(Request::Del { slug: slug.to_string(), }) } "LIST" => Ok(Request::List), "STATUS" => Ok(Request::Status), "SHUTDOWN" => Ok(Request::Shutdown), _ => Err(format!("unknown command: {cmd}")), } } /// Serialize a [`Response`] into a protocol line. pub fn format_response(r: &Response) -> String { match r { Response::Ok(data) => format!("OK {data}\n"), Response::Err(msg) => format!("ERR {msg}\n"), } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_shorten() { let r = parse_request("SHORTEN 0 auto https://example.com").unwrap(); match r { Request::Shorten { ttl_secs, slug, target_url, } => { assert_eq!(ttl_secs, 0); assert_eq!(slug, "auto"); assert_eq!(target_url, "https://example.com"); } _ => panic!("expected Shorten"), } } #[test] fn parse_shorten_custom_slug() { let r = parse_request("SHORTEN 3600 myslug https://example.com").unwrap(); match r { Request::Shorten { slug, .. } => assert_eq!(slug, "myslug"), _ => panic!("expected Shorten"), } } #[test] fn parse_resolve() { let r = parse_request("RESOLVE abc123").unwrap(); match r { Request::Resolve { slug } => assert_eq!(slug, "abc123"), _ => panic!("expected Resolve"), } } #[test] fn parse_status() { assert!(matches!( parse_request("STATUS").unwrap(), Request::Status )); } #[test] fn parse_list() { assert!(matches!(parse_request("LIST").unwrap(), Request::List)); } #[test] fn parse_empty_fails() { assert!(parse_request("").is_err()); } #[test] fn parse_unknown_fails() { assert!(parse_request("FOOBAR").is_err()); } }