use std::collections::BTreeMap;
use std::fmt;
use crate::error::{ParseError, Result};
use crate::tokenize::{quote_value, strip_quotes, Tokenizer};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Request {
pub primitive: String,
pub args: Vec<String>,
pub flags: BTreeMap<String, Option<String>>,
}
impl Request {
#[must_use]
pub fn new(primitive: impl Into<String>) -> Self {
Self {
primitive: primitive.into(),
args: Vec::new(),
flags: BTreeMap::new(),
}
}
#[must_use]
pub fn arg(mut self, value: impl Into<String>) -> Self {
self.args.push(value.into());
self
}
#[must_use]
pub fn flag(mut self, name: impl Into<String>) -> Self {
self.flags.insert(name.into(), None);
self
}
#[must_use]
pub fn flag_value(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.flags.insert(name.into(), Some(value.into()));
self
}
#[must_use]
pub fn encode(&self) -> String {
let mut out = self.primitive.clone();
for arg in &self.args {
out.push(' ');
out.push_str("e_value(arg));
}
for (k, v) in &self.flags {
out.push(' ');
out.push_str("--");
out.push_str(k);
if let Some(value) = v {
out.push('=');
out.push_str("e_value(value));
}
}
out.push('\n');
out
}
pub fn parse(line: &str) -> Result<Self> {
let trimmed = line.trim_end_matches('\n').trim();
if trimmed.is_empty() {
return Err(ParseError::InvalidRequest {
detail: "empty request line",
});
}
let mut tokens = Tokenizer::new(trimmed);
let primitive_tok = tokens.next().ok_or(ParseError::InvalidRequest {
detail: "missing primitive",
})?;
if !is_valid_primitive(primitive_tok) {
return Err(ParseError::InvalidRequest {
detail: "invalid primitive name",
});
}
let mut args = Vec::new();
let mut flags = BTreeMap::new();
for tok in tokens {
if let Some(rest) = tok.strip_prefix("--") {
if rest.is_empty() {
return Err(ParseError::InvalidRequest {
detail: "flag with no name",
});
}
if let Some((name, value)) = rest.split_once('=') {
if name.is_empty() {
return Err(ParseError::InvalidRequest {
detail: "flag with empty name",
});
}
flags.insert(name.to_string(), Some(strip_quotes(value).to_string()));
} else {
flags.insert(rest.to_string(), None);
}
} else {
args.push(strip_quotes(tok).to_string());
}
}
Ok(Self {
primitive: primitive_tok.to_string(),
args,
flags,
})
}
}
impl fmt::Display for Request {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.encode().trim_end_matches('\n'))
}
}
fn is_valid_primitive(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn primitive_only() {
let r = Request::new("vs_view");
let s = r.encode();
assert_eq!(s, "vs_view\n");
assert_eq!(Request::parse(&s).unwrap(), r);
}
#[test]
fn with_arg() {
let r = Request::new("vs_open").arg("https://example.com");
let s = r.encode();
assert_eq!(s, "vs_open https://example.com\n");
assert_eq!(Request::parse(&s).unwrap(), r);
}
#[test]
fn with_quoted_arg() {
let r = Request::new("vs_act")
.arg("2")
.arg("fill")
.arg("frane@example.com");
let s = r.encode();
assert_eq!(s, "vs_act 2 fill frane@example.com\n");
assert_eq!(Request::parse(&s).unwrap(), r);
}
#[test]
fn arg_with_spaces_quoted() {
let r = Request::new("vs_act")
.arg("2")
.arg("fill")
.arg("with spaces");
let s = r.encode();
assert_eq!(s, "vs_act 2 fill \"with spaces\"\n");
assert_eq!(Request::parse(&s).unwrap(), r);
}
#[test]
fn bare_flag() {
let r = Request::new("vs_capture").flag("full-page");
let s = r.encode();
assert_eq!(s, "vs_capture --full-page\n");
assert_eq!(Request::parse(&s).unwrap(), r);
}
#[test]
fn flag_with_value() {
let r = Request::new("vs_capture").flag_value("viewport", "mobile");
let s = r.encode();
assert_eq!(s, "vs_capture --viewport=mobile\n");
assert_eq!(Request::parse(&s).unwrap(), r);
}
#[test]
fn flag_value_with_spaces_quoted() {
let r = Request::new("vs_session_open").flag_value("policy", "strict mode");
let s = r.encode();
assert_eq!(s, "vs_session_open --policy=\"strict mode\"\n");
assert_eq!(Request::parse(&s).unwrap(), r);
}
#[test]
fn full_request() {
let r = Request::new("vs_capture")
.arg("7")
.flag("full-page")
.flag_value("viewport", "mobile");
let s = r.encode();
assert_eq!(s, "vs_capture 7 --full-page --viewport=mobile\n");
assert_eq!(Request::parse(&s).unwrap(), r);
}
#[test]
fn rejects_invalid_primitive() {
for bad in ["", "VS_VIEW", "--vs", "vs!", "vs-cli"] {
assert!(Request::parse(bad).is_err(), "{bad} should fail");
}
}
}