Skip to main content

newgit_core/
exports.rs

1use std::collections::BTreeMap;
2
3/// The minimal template variable set for exports and action commands:
4/// `{{ports.<name>}}`, `{{branch.name}}`, `{{branch.slug}}`, `{{workspace}}`.
5///
6/// Checkpoint and restore commands additionally see `{{exports.<name>}}`,
7/// `{{snapshot.path}}` (the staging dir for `into_tracker` deposits), and
8/// `{{state_ref}}` (the checkpointed state reference). Those stay `None`
9/// everywhere else — action and export rendering is deliberately minimal.
10#[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
41/// The first `{{...}}` a render left behind, if any.
42///
43/// Rendering deliberately leaves unknown variables verbatim so a
44/// misconfigured template is visible rather than silently emptied. That is
45/// the right default for a command the user watches run, but destructive
46/// hooks (cleanup) must refuse instead: `cloudctl preview delete
47/// {{state_ref}}` with no state ref is not a no-op, it is a wrong argument.
48pub 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        // Absent variables are left verbatim, so a misconfigured template is
97        // visible in the command instead of silently emptied.
98        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        // An unterminated brace pair is not a placeholder newgit can name.
112        assert_eq!(unresolved_placeholder("echo {{oops"), None);
113    }
114}