diff options
Diffstat (limited to 'src/protocol.rs')
| -rw-r--r-- | src/protocol.rs | 158 |
1 files changed, 158 insertions, 0 deletions
diff --git a/src/protocol.rs b/src/protocol.rs new file mode 100644 index 0000000..60d2fa9 --- /dev/null +++ b/src/protocol.rs @@ -0,0 +1,158 @@ +//! Unix socket protocol for daemon ↔ CLI. +//! +//! Simple line-oriented text protocol: +//! SHORTEN <ttl_secs> <slug|auto> <url>\n +//! RESOLVE <slug>\n +//! DEL <slug>\n +//! LIST\n +//! STATUS\n +//! SHUTDOWN\n +//! +//! Responses: +//! OK <data>\n +//! ERR <message>\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<Request, String> { + 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 <ttl> <slug> <url>")?; + let slug = parts + .next() + .ok_or("SHORTEN requires: SHORTEN <ttl> <slug> <url>")?; + let target_url = parts + .next() + .ok_or("SHORTEN requires: SHORTEN <ttl> <slug> <url>")?; + 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 <slug>")?; + Ok(Request::Resolve { + slug: slug.to_string(), + }) + } + "DEL" => { + let slug = parts.next().ok_or("DEL requires: DEL <slug>")?; + 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()); + } +} |