Skip to main content

vs_protocol/
request.rs

1//! Request line: `<primitive> [arg]... [--flag[=val]]...`
2//!
3//! See `docs/PROTOCOL.md` § "Request line". The primitive is a lowercase
4//! identifier; positional args may be bare or `"..."`-quoted; flags use
5//! the long form (`--name` or `--name=value`). The wire format does not
6//! carry short flags — the CLI may accept them and translate before
7//! sending.
8
9use std::collections::BTreeMap;
10use std::fmt;
11
12use crate::error::{ParseError, Result};
13use crate::tokenize::{quote_value, strip_quotes, Tokenizer};
14
15/// One request from the CLI to the daemon.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Request {
18    /// Primitive name, e.g. `vs_open`.
19    pub primitive: String,
20    /// Positional arguments, in order.
21    pub args: Vec<String>,
22    /// Flags. `None` means a bare flag (e.g. `--full-page`); `Some(v)`
23    /// means `--name=v`.
24    pub flags: BTreeMap<String, Option<String>>,
25}
26
27impl Request {
28    #[must_use]
29    pub fn new(primitive: impl Into<String>) -> Self {
30        Self {
31            primitive: primitive.into(),
32            args: Vec::new(),
33            flags: BTreeMap::new(),
34        }
35    }
36
37    #[must_use]
38    pub fn arg(mut self, value: impl Into<String>) -> Self {
39        self.args.push(value.into());
40        self
41    }
42
43    #[must_use]
44    pub fn flag(mut self, name: impl Into<String>) -> Self {
45        self.flags.insert(name.into(), None);
46        self
47    }
48
49    #[must_use]
50    pub fn flag_value(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
51        self.flags.insert(name.into(), Some(value.into()));
52        self
53    }
54
55    /// Encode the request as a single wire line, including the trailing
56    /// newline.
57    #[must_use]
58    pub fn encode(&self) -> String {
59        let mut out = self.primitive.clone();
60        for arg in &self.args {
61            out.push(' ');
62            out.push_str(&quote_value(arg));
63        }
64        for (k, v) in &self.flags {
65            out.push(' ');
66            out.push_str("--");
67            out.push_str(k);
68            if let Some(value) = v {
69                out.push('=');
70                out.push_str(&quote_value(value));
71            }
72        }
73        out.push('\n');
74        out
75    }
76
77    /// Parse one request line. Trailing newline optional.
78    pub fn parse(line: &str) -> Result<Self> {
79        let trimmed = line.trim_end_matches('\n').trim();
80        if trimmed.is_empty() {
81            return Err(ParseError::InvalidRequest {
82                detail: "empty request line",
83            });
84        }
85        let mut tokens = Tokenizer::new(trimmed);
86        let primitive_tok = tokens.next().ok_or(ParseError::InvalidRequest {
87            detail: "missing primitive",
88        })?;
89        // Validate the primitive shape: lowercase identifier with
90        // optional underscores.
91        if !is_valid_primitive(primitive_tok) {
92            return Err(ParseError::InvalidRequest {
93                detail: "invalid primitive name",
94            });
95        }
96        let mut args = Vec::new();
97        let mut flags = BTreeMap::new();
98        for tok in tokens {
99            if let Some(rest) = tok.strip_prefix("--") {
100                if rest.is_empty() {
101                    return Err(ParseError::InvalidRequest {
102                        detail: "flag with no name",
103                    });
104                }
105                if let Some((name, value)) = rest.split_once('=') {
106                    if name.is_empty() {
107                        return Err(ParseError::InvalidRequest {
108                            detail: "flag with empty name",
109                        });
110                    }
111                    flags.insert(name.to_string(), Some(strip_quotes(value).to_string()));
112                } else {
113                    flags.insert(rest.to_string(), None);
114                }
115            } else {
116                args.push(strip_quotes(tok).to_string());
117            }
118        }
119        Ok(Self {
120            primitive: primitive_tok.to_string(),
121            args,
122            flags,
123        })
124    }
125}
126
127impl fmt::Display for Request {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        f.write_str(self.encode().trim_end_matches('\n'))
130    }
131}
132
133fn is_valid_primitive(s: &str) -> bool {
134    !s.is_empty()
135        && s.chars()
136            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn primitive_only() {
145        let r = Request::new("vs_view");
146        let s = r.encode();
147        assert_eq!(s, "vs_view\n");
148        assert_eq!(Request::parse(&s).unwrap(), r);
149    }
150
151    #[test]
152    fn with_arg() {
153        let r = Request::new("vs_open").arg("https://example.com");
154        let s = r.encode();
155        assert_eq!(s, "vs_open https://example.com\n");
156        assert_eq!(Request::parse(&s).unwrap(), r);
157    }
158
159    #[test]
160    fn with_quoted_arg() {
161        let r = Request::new("vs_act")
162            .arg("2")
163            .arg("fill")
164            .arg("frane@example.com");
165        let s = r.encode();
166        assert_eq!(s, "vs_act 2 fill frane@example.com\n");
167        assert_eq!(Request::parse(&s).unwrap(), r);
168    }
169
170    #[test]
171    fn arg_with_spaces_quoted() {
172        let r = Request::new("vs_act")
173            .arg("2")
174            .arg("fill")
175            .arg("with spaces");
176        let s = r.encode();
177        assert_eq!(s, "vs_act 2 fill \"with spaces\"\n");
178        assert_eq!(Request::parse(&s).unwrap(), r);
179    }
180
181    #[test]
182    fn bare_flag() {
183        let r = Request::new("vs_capture").flag("full-page");
184        let s = r.encode();
185        assert_eq!(s, "vs_capture --full-page\n");
186        assert_eq!(Request::parse(&s).unwrap(), r);
187    }
188
189    #[test]
190    fn flag_with_value() {
191        let r = Request::new("vs_capture").flag_value("viewport", "mobile");
192        let s = r.encode();
193        assert_eq!(s, "vs_capture --viewport=mobile\n");
194        assert_eq!(Request::parse(&s).unwrap(), r);
195    }
196
197    #[test]
198    fn flag_value_with_spaces_quoted() {
199        let r = Request::new("vs_session_open").flag_value("policy", "strict mode");
200        let s = r.encode();
201        assert_eq!(s, "vs_session_open --policy=\"strict mode\"\n");
202        assert_eq!(Request::parse(&s).unwrap(), r);
203    }
204
205    #[test]
206    fn full_request() {
207        let r = Request::new("vs_capture")
208            .arg("7")
209            .flag("full-page")
210            .flag_value("viewport", "mobile");
211        let s = r.encode();
212        // Flags ordered alphabetically.
213        assert_eq!(s, "vs_capture 7 --full-page --viewport=mobile\n");
214        assert_eq!(Request::parse(&s).unwrap(), r);
215    }
216
217    #[test]
218    fn rejects_invalid_primitive() {
219        for bad in ["", "VS_VIEW", "--vs", "vs!", "vs-cli"] {
220            assert!(Request::parse(bad).is_err(), "{bad} should fail");
221        }
222    }
223}