1use std::collections::BTreeMap;
2
3#[derive(Debug, Clone, Default)]
11pub struct RenderContext<'a> {
12 pub branch_name: &'a str,
13 pub branch_slug: &'a str,
14 pub workspace: &'a str,
15 pub ports: Option<&'a BTreeMap<String, u16>>,
16 pub exports: Option<&'a BTreeMap<String, String>>,
17 pub snapshot_path: Option<&'a str>,
18 pub state_ref: Option<&'a str>,
19}
20
21pub fn render(template: &str, context: &RenderContext) -> String {
22 let mut rendered = template
23 .replace("{{branch.name}}", context.branch_name)
24 .replace("{{branch.slug}}", context.branch_slug)
25 .replace("{{workspace}}", context.workspace);
26 for (name, port) in context.ports.into_iter().flatten() {
27 rendered = rendered.replace(&format!("{{{{ports.{name}}}}}"), &port.to_string());
28 }
29 for (name, value) in context.exports.into_iter().flatten() {
30 rendered = rendered.replace(&format!("{{{{exports.{name}}}}}"), value);
31 }
32 if let Some(path) = context.snapshot_path {
33 rendered = rendered.replace("{{snapshot.path}}", path);
34 }
35 if let Some(state_ref) = context.state_ref {
36 rendered = rendered.replace("{{state_ref}}", state_ref);
37 }
38 rendered
39}
40
41pub fn unresolved_placeholder(rendered: &str) -> Option<&str> {
49 let start = rendered.find("{{")?;
50 let rest = &rendered[start..];
51 let end = rest.find("}}")? + 2;
52 Some(&rest[..end])
53}
54
55#[cfg(test)]
56mod tests {
57 use std::collections::BTreeMap;
58
59 use super::{RenderContext, render, unresolved_placeholder};
60
61 #[test]
62 fn renders_ports_and_branch_vars() {
63 let ports = BTreeMap::from([("app".to_owned(), 3107)]);
64 let context = RenderContext {
65 branch_name: "feature/a",
66 branch_slug: "feature-a",
67 workspace: "/ws",
68 ports: Some(&ports),
69 ..RenderContext::default()
70 };
71 assert_eq!(
72 render("http://127.0.0.1:{{ports.app}}/{{branch.slug}}", &context),
73 "http://127.0.0.1:3107/feature-a"
74 );
75 }
76
77 #[test]
78 fn renders_checkpoint_vars_only_when_provided() {
79 let exports = BTreeMap::from([("PREVIEW_ID".to_owned(), "pv_9".to_owned())]);
80 let context = RenderContext {
81 branch_name: "a",
82 branch_slug: "a",
83 workspace: "/ws",
84 exports: Some(&exports),
85 snapshot_path: Some("/stage"),
86 state_ref: Some("/snap/db.sql"),
87 ..RenderContext::default()
88 };
89 assert_eq!(
90 render(
91 "{{exports.PREVIEW_ID}} {{snapshot.path}}/db.sql < {{state_ref}}",
92 &context
93 ),
94 "pv_9 /stage/db.sql < /snap/db.sql"
95 );
96 assert_eq!(
99 render("{{state_ref}}", &RenderContext::default()),
100 "{{state_ref}}"
101 );
102 }
103
104 #[test]
105 fn unresolved_placeholders_are_reported_for_refusal() {
106 assert_eq!(
107 unresolved_placeholder("delete {{state_ref}} --force"),
108 Some("{{state_ref}}")
109 );
110 assert_eq!(unresolved_placeholder("delete pv_9"), None);
111 assert_eq!(unresolved_placeholder("echo {{oops"), None);
113 }
114}