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/// `{{scripts}}`.
6///
7/// Checkpoint and restore commands additionally see `{{exports.<name>}}`,
8/// `{{snapshot.path}}` (the staging dir for `into_tracker` deposits), and
9/// `{{state_ref}}` (the checkpointed state reference). Those stay `None`
10/// everywhere else — action and export rendering is deliberately minimal.
11#[derive(Debug, Clone, Default)]
12pub struct RenderContext<'a> {
13    pub branch_name: &'a str,
14    pub branch_slug: &'a str,
15    pub workspace: &'a str,
16    /// The store's `.newgit/scripts/`, so a command can shell out to a script
17    /// that lives beside the definition calling it instead of in the
18    /// workspace, where it would be subject to source materialization.
19    pub scripts: &'a str,
20    pub ports: Option<&'a BTreeMap<String, u16>>,
21    pub exports: Option<&'a BTreeMap<String, String>>,
22    pub snapshot_path: Option<&'a str>,
23    pub state_ref: Option<&'a str>,
24}
25
26pub fn render(template: &str, context: &RenderContext) -> String {
27    let mut rendered = template
28        .replace("{{branch.name}}", context.branch_name)
29        .replace("{{branch.slug}}", context.branch_slug)
30        .replace("{{workspace}}", context.workspace)
31        .replace("{{scripts}}", context.scripts);
32    for (name, port) in context.ports.into_iter().flatten() {
33        rendered = rendered.replace(&format!("{{{{ports.{name}}}}}"), &port.to_string());
34    }
35    for (name, value) in context.exports.into_iter().flatten() {
36        rendered = rendered.replace(&format!("{{{{exports.{name}}}}}"), value);
37    }
38    if let Some(path) = context.snapshot_path {
39        rendered = rendered.replace("{{snapshot.path}}", path);
40    }
41    if let Some(state_ref) = context.state_ref {
42        rendered = rendered.replace("{{state_ref}}", state_ref);
43    }
44    rendered
45}
46
47/// The first `{{...}}` a render left behind, if any.
48///
49/// Rendering deliberately leaves unknown variables verbatim so a
50/// misconfigured template is visible rather than silently emptied. That is
51/// the right default for a command the user watches run, but destructive
52/// hooks (cleanup) must refuse instead: `cloudctl preview delete
53/// {{state_ref}}` with no state ref is not a no-op, it is a wrong argument.
54pub fn unresolved_placeholder(rendered: &str) -> Option<&str> {
55    let start = rendered.find("{{")?;
56    let rest = &rendered[start..];
57    let end = rest.find("}}")? + 2;
58    Some(&rest[..end])
59}
60
61#[cfg(test)]
62mod tests {
63    use std::collections::BTreeMap;
64
65    use super::{RenderContext, render, unresolved_placeholder};
66
67    #[test]
68    fn renders_ports_and_branch_vars() {
69        let ports = BTreeMap::from([("app".to_owned(), 3107)]);
70        let context = RenderContext {
71            branch_name: "feature/a",
72            branch_slug: "feature-a",
73            workspace: "/ws",
74            ports: Some(&ports),
75            ..RenderContext::default()
76        };
77        assert_eq!(
78            render("http://127.0.0.1:{{ports.app}}/{{branch.slug}}", &context),
79            "http://127.0.0.1:3107/feature-a"
80        );
81    }
82
83    #[test]
84    fn renders_checkpoint_vars_only_when_provided() {
85        let exports = BTreeMap::from([("PREVIEW_ID".to_owned(), "pv_9".to_owned())]);
86        let context = RenderContext {
87            branch_name: "a",
88            branch_slug: "a",
89            workspace: "/ws",
90            exports: Some(&exports),
91            snapshot_path: Some("/stage"),
92            state_ref: Some("/snap/db.sql"),
93            ..RenderContext::default()
94        };
95        assert_eq!(
96            render(
97                "{{exports.PREVIEW_ID}} {{snapshot.path}}/db.sql < {{state_ref}}",
98                &context
99            ),
100            "pv_9 /stage/db.sql < /snap/db.sql"
101        );
102        // Absent variables are left verbatim, so a misconfigured template is
103        // visible in the command instead of silently emptied.
104        assert_eq!(
105            render("{{state_ref}}", &RenderContext::default()),
106            "{{state_ref}}"
107        );
108    }
109
110    #[test]
111    fn unresolved_placeholders_are_reported_for_refusal() {
112        assert_eq!(
113            unresolved_placeholder("delete {{state_ref}} --force"),
114            Some("{{state_ref}}")
115        );
116        assert_eq!(unresolved_placeholder("delete pv_9"), None);
117        // An unterminated brace pair is not a placeholder newgit can name.
118        assert_eq!(unresolved_placeholder("echo {{oops"), None);
119    }
120}