1use std::collections::BTreeMap;
10use std::fmt;
11
12use crate::error::{ParseError, Result};
13use crate::tokenize::{quote_value, strip_quotes, Tokenizer};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Request {
18 pub primitive: String,
20 pub args: Vec<String>,
22 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 #[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("e_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("e_value(value));
71 }
72 }
73 out.push('\n');
74 out
75 }
76
77 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 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 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}