1use std::collections::BTreeMap;
36use std::fmt;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Kind {
43 Link,
45 SignedLink,
49 Connection,
53 Custom,
55 Other,
59}
60
61impl Kind {
62 pub fn parse(s: &str) -> Self {
63 match s {
64 "link" => Kind::Link,
65 "signed_link" => Kind::SignedLink,
66 "connection" => Kind::Connection,
67 "custom" => Kind::Custom,
68 _ => Kind::Other,
69 }
70 }
71
72 fn is_url_shaped(self) -> bool {
73 matches!(self, Kind::Link | Kind::SignedLink | Kind::Connection)
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81enum Zone {
82 Unstructured,
85 UserInfo,
86 Host,
87 Path,
88 QueryName,
89 QueryValue,
90 Fragment,
91}
92
93#[derive(Debug, Clone)]
95pub struct Part {
96 pub key: String,
97 pub value: String,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum RenderError {
103 UnfilledPlaceholder(String),
106 UnbalancedBrace(usize),
109 ControlCharacterInPart(String),
112}
113
114impl fmt::Display for RenderError {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 match self {
117 RenderError::UnfilledPlaceholder(name) => {
118 write!(
119 f,
120 "no part named \"{name}\" — the template cannot render without it"
121 )
122 }
123 RenderError::UnbalancedBrace(at) => {
124 write!(
125 f,
126 "unbalanced '{{' at position {at} — use '{{{{' for a literal brace"
127 )
128 }
129 RenderError::ControlCharacterInPart(name) => {
130 write!(f, "part \"{name}\" contains a newline or control character, which a URL cannot carry")
131 }
132 }
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct Rendered {
139 pub text: String,
140 pub used: Vec<String>,
142 pub unused: Vec<String>,
145}
146
147fn percent_encode(s: &str) -> String {
152 let mut out = String::with_capacity(s.len());
153 for b in s.bytes() {
154 match b {
155 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
156 out.push(b as char);
157 }
158 _ => out.push_str(&format!("%{b:02X}")),
159 }
160 }
161 out
162}
163
164fn has_control_char(s: &str) -> bool {
165 s.chars().any(|c| c.is_control())
166}
167
168enum Token {
170 Literal(String),
171 Placeholder(String, usize),
174}
175
176fn tokenize(template: &str) -> Result<Vec<Token>, RenderError> {
179 let bytes = template.as_bytes();
180 let mut tokens = Vec::new();
181 let mut literal = String::new();
182 let mut i = 0;
183 while i < bytes.len() {
184 match bytes[i] {
185 b'{' if bytes.get(i + 1) == Some(&b'{') => {
186 literal.push('{');
187 i += 2;
188 }
189 b'}' if bytes.get(i + 1) == Some(&b'}') => {
190 literal.push('}');
191 i += 2;
192 }
193 b'{' => {
194 let start = i;
195 let close = template[i..].find('}').map(|p| p + i);
196 let close = match close {
197 Some(c) => c,
198 None => return Err(RenderError::UnbalancedBrace(start)),
199 };
200 let name = &template[i + 1..close];
201 if name.is_empty() || !is_valid_name(name) {
202 return Err(RenderError::UnbalancedBrace(start));
203 }
204 if !literal.is_empty() {
205 tokens.push(Token::Literal(std::mem::take(&mut literal)));
206 }
207 tokens.push(Token::Placeholder(name.to_string(), start));
208 i = close + 1;
209 }
210 b'}' => return Err(RenderError::UnbalancedBrace(i)),
211 _ => {
212 let ch = template[i..].chars().next().unwrap();
215 literal.push(ch);
216 i += ch.len_utf8();
217 }
218 }
219 }
220 if !literal.is_empty() {
221 tokens.push(Token::Literal(literal));
222 }
223 Ok(tokens)
224}
225
226fn is_valid_name(s: &str) -> bool {
227 let mut chars = s.chars();
228 match chars.next() {
229 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
230 _ => return false,
231 }
232 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
233}
234
235fn classify_zone(template: &str, at: usize) -> Zone {
239 let scheme_end = match template.find("://") {
240 Some(p) => p + 3,
241 None => return Zone::Unstructured,
242 };
243 if at < scheme_end {
244 return Zone::Unstructured;
245 }
246 let authority_end = template[scheme_end..]
248 .find(['/', '?', '#'])
249 .map(|p| p + scheme_end)
250 .unwrap_or(template.len());
251 if at < authority_end {
252 let authority = &template[scheme_end..authority_end];
253 if let Some(at_sign) = authority.find('@') {
254 let at_sign = at_sign + scheme_end;
255 return if at < at_sign {
256 Zone::UserInfo
257 } else {
258 Zone::Host
259 };
260 }
261 return Zone::Host;
262 }
263 let query_start = template[authority_end..]
264 .find('?')
265 .map(|p| p + authority_end);
266 let fragment_start = template[authority_end..]
267 .find('#')
268 .map(|p| p + authority_end);
269 if let Some(fs) = fragment_start {
270 if at >= fs {
271 return Zone::Fragment;
272 }
273 }
274 if let Some(qs) = query_start {
275 if at >= qs {
276 let query_end = fragment_start.unwrap_or(template.len());
277 let query = &template[qs..query_end];
278 let rel = at - qs;
281 let seg_start = query[..rel].rfind('&').map(|p| p + 1).unwrap_or(0);
282 let seg_end = query[rel..]
283 .find('&')
284 .map(|p| p + rel)
285 .unwrap_or(query.len());
286 let segment = &query[seg_start..seg_end];
287 return match segment.find('=') {
288 Some(eq) if rel - seg_start > eq => Zone::QueryValue,
289 Some(_) => Zone::QueryName,
290 None => Zone::QueryName,
291 };
292 }
293 }
294 Zone::Path
295}
296
297pub fn render(template: &str, parts: &[Part], kind: Kind) -> Result<Rendered, RenderError> {
304 let by_name: BTreeMap<&str, &str> = parts
305 .iter()
306 .map(|p| (p.key.as_str(), p.value.as_str()))
307 .collect();
308 let tokens = tokenize(template)?;
309
310 let mut text = String::new();
311 let mut used = Vec::new();
312 let encode = kind.is_url_shaped();
313
314 for tok in &tokens {
315 match tok {
316 Token::Literal(s) => text.push_str(s),
317 Token::Placeholder(name, at) => {
318 let value = *by_name
319 .get(name.as_str())
320 .ok_or_else(|| RenderError::UnfilledPlaceholder(name.clone()))?;
321 if encode && has_control_char(value) {
322 return Err(RenderError::ControlCharacterInPart(name.clone()));
323 }
324 if !used.contains(name) {
325 used.push(name.clone());
326 }
327 if encode {
328 let zone = classify_zone(template, *at);
329 text.push_str(&match zone {
330 Zone::Unstructured => value.to_string(),
331 Zone::UserInfo
332 | Zone::Host
333 | Zone::Path
334 | Zone::QueryName
335 | Zone::QueryValue
336 | Zone::Fragment => percent_encode(value),
337 });
338 } else {
339 text.push_str(value);
340 }
341 }
342 }
343 }
344
345 let unused: Vec<String> = parts
346 .iter()
347 .map(|p| p.key.clone())
348 .filter(|k| !used.contains(k))
349 .collect();
350
351 Ok(Rendered { text, used, unused })
352}
353
354pub fn placeholders(template: &str) -> Result<Vec<String>, RenderError> {
359 let mut names = Vec::new();
360 for tok in tokenize(template)? {
361 if let Token::Placeholder(name, _) = tok {
362 if !names.contains(&name) {
363 names.push(name);
364 }
365 }
366 }
367 Ok(names)
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 fn part(k: &str, v: &str) -> Part {
375 Part {
376 key: k.into(),
377 value: v.into(),
378 }
379 }
380
381 #[test]
382 fn renders_a_calendar_url_with_two_parts() {
383 let tpl = "https://outlook.office365.com/owa/calendar/{mailbox_id}@inf.elte.hu/{calendar_key}/calendar.ics";
384 let r = render(
385 tpl,
386 &[
387 part("mailbox_id", "js.doe"),
388 part("calendar_key", "abc-123"),
389 ],
390 Kind::Link,
391 )
392 .unwrap();
393 assert_eq!(
394 r.text,
395 "https://outlook.office365.com/owa/calendar/js.doe@inf.elte.hu/abc-123/calendar.ics"
396 );
397 assert_eq!(r.used, vec!["mailbox_id", "calendar_key"]);
398 assert!(r.unused.is_empty());
399 }
400
401 #[test]
402 fn refuses_an_unfilled_placeholder() {
403 let err = render("https://x/{a}", &[], Kind::Link).unwrap_err();
404 assert_eq!(err, RenderError::UnfilledPlaceholder("a".into()));
405 }
406
407 #[test]
408 fn double_braces_are_literal() {
409 let r = render("{{literal}}", &[], Kind::Custom).unwrap();
410 assert_eq!(r.text, "{literal}");
411 }
412
413 #[test]
414 fn an_unbalanced_brace_is_a_form_error_not_a_literal() {
415 assert!(matches!(
416 render("https://x/{oops", &[], Kind::Link),
417 Err(RenderError::UnbalancedBrace(_))
418 ));
419 }
420
421 #[test]
422 fn one_placeholder_used_twice_is_one_part_filled_twice() {
423 let r = render("{tok}/x/{tok}", &[part("tok", "abc")], Kind::Custom).unwrap();
425 assert_eq!(r.text, "abc/x/abc");
426 assert_eq!(r.used, vec!["tok"]);
427 }
428
429 #[test]
430 fn a_part_no_placeholder_uses_is_reported_not_refused() {
431 let r = render("no holes here", &[part("orphan", "v")], Kind::Custom).unwrap();
432 assert_eq!(r.unused, vec!["orphan"]);
433 }
434
435 #[test]
436 fn userinfo_encoding_stops_at_and_colon_from_moving_the_host() {
437 let r = render(
439 "postgres://{user}:{pass}@db.example.com/prod",
440 &[part("user", "app"), part("pass", "p@ss:word")],
441 Kind::Connection,
442 )
443 .unwrap();
444 assert_eq!(r.text, "postgres://app:p%40ss%3Aword@db.example.com/prod");
445 }
446
447 #[test]
448 fn query_value_is_encoded_and_query_name_is_too() {
449 let r = render(
450 "https://x/?{name}={value}",
451 &[
452 part("name", "a b"),
453 part("value", "bot applications.commands"),
454 ],
455 Kind::Link,
456 )
457 .unwrap();
458 assert_eq!(r.text, "https://x/?a%20b=bot%20applications.commands");
459 }
460
461 #[test]
462 fn custom_never_encodes() {
463 let r = render("{a}", &[part("a", "sp ace/slash")], Kind::Custom).unwrap();
464 assert_eq!(r.text, "sp ace/slash");
465 }
466
467 #[test]
468 fn control_character_in_a_url_shaped_part_is_refused() {
469 let err = render("https://x/{a}", &[part("a", "line\nbreak")], Kind::Link).unwrap_err();
470 assert_eq!(err, RenderError::ControlCharacterInPart("a".into()));
471 }
472
473 #[test]
474 fn control_character_in_a_custom_part_is_allowed() {
475 let r = render("{a}", &[part("a", "line\nbreak")], Kind::Custom).unwrap();
476 assert_eq!(r.text, "line\nbreak");
477 }
478
479 #[test]
480 fn placeholders_lists_names_with_no_parts_needed() {
481 assert_eq!(
482 placeholders("https://x/{a}/{b}/{a}").unwrap(),
483 vec!["a".to_string(), "b".to_string()]
484 );
485 }
486
487 #[test]
488 fn an_unknown_kind_string_behaves_like_custom() {
489 assert_eq!(Kind::parse("nonsense"), Kind::Other);
492 let r = render("{a}", &[part("a", "raw value")], Kind::parse("nonsense")).unwrap();
493 assert_eq!(r.text, "raw value");
494 }
495}