Skip to main content

create_grafana_plugin/
template.rs

1//! Template discovery, rendering, and [`TemplateContext`] for scaffold and update flows.
2//!
3//! Templates are embedded into the binary at compile time via [`include_dir`].
4
5use anyhow::{Context, Result};
6use include_dir::{Dir, DirEntry, include_dir};
7use serde::Serialize;
8use std::path::{Path, PathBuf};
9use tera::Tera;
10
11use crate::config::ProjectConfig;
12
13/// All template files under `templates/`, baked into the binary at compile time.
14static TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates");
15
16/// Render embedded template content to bytes.
17///
18/// If `rel_path` ends with `.tera`, the content is rendered through Tera; otherwise it is
19/// returned verbatim.  Binary files (images, fonts) are always returned as-is.
20///
21/// # Errors
22///
23/// Returns an error when the template fails to render.
24pub fn render_to_bytes(
25    contents: &[u8],
26    rel_path: &Path,
27    context: &TemplateContext,
28) -> Result<Vec<u8>> {
29    if is_binary_path(rel_path) || rel_path.extension().and_then(|e| e.to_str()) != Some("tera") {
30        Ok(contents.to_vec())
31    } else {
32        let template_body =
33            std::str::from_utf8(contents).context("Template file is not valid UTF-8")?;
34        let rendered = render_string(template_body, context)?;
35        Ok(rendered.into_bytes())
36    }
37}
38
39/// Template rendering context with user config + computed fields
40#[derive(Debug, Serialize)]
41pub struct TemplateContext {
42    // User config
43    pub plugin_name: String,
44    pub plugin_description: String,
45    pub author: String,
46    pub org: String,
47    pub plugin_type: String,
48    pub has_wasm: bool,
49    pub has_docker: bool,
50    pub has_mock: bool,
51    pub port_offset: u16,
52
53    // Computed fields
54    pub plugin_id: String,
55    pub crate_name: String,
56    pub current_year: String,
57    pub today: String,
58    pub pascal_case_name: String,
59}
60
61impl TemplateContext {
62    /// Build context from project config with computed fields
63    pub fn from_config(config: &ProjectConfig) -> Self {
64        let plugin_id = format!("{}-{}", config.org, config.name);
65        let crate_name = config.name.replace('-', "_");
66        let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
67        let year = chrono::Utc::now().format("%Y").to_string();
68
69        let pascal_case_name = config
70            .name
71            .split('-')
72            .map(|word| {
73                let mut chars = word.chars();
74                match chars.next() {
75                    None => String::new(),
76                    Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
77                }
78            })
79            .collect::<String>();
80
81        Self {
82            plugin_name: config.name.clone(),
83            plugin_description: config.description.clone(),
84            author: config.author.clone(),
85            org: config.org.clone(),
86            plugin_type: config.plugin_type.to_string(),
87            has_wasm: config.has_wasm,
88            has_docker: config.has_docker,
89            has_mock: config.has_mock,
90            port_offset: config.port_offset,
91            plugin_id,
92            crate_name,
93            current_year: year,
94            today,
95            pascal_case_name,
96        }
97    }
98
99    /// Prefer `plugin.json` `info.updated` and derived year so updates do not churn dates.
100    pub fn apply_dates_from_existing_plugin_json(&mut self, project_dir: &Path) {
101        let plugin_path = project_dir.join("plugin.json");
102        let Ok(raw) = std::fs::read_to_string(&plugin_path) else {
103            return;
104        };
105        let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
106            return;
107        };
108        let Some(u) = v
109            .get("info")
110            .and_then(|info| info.get("updated"))
111            .and_then(|x| x.as_str())
112        else {
113            return;
114        };
115        self.today = u.to_string();
116        if let Some(y) = u.split('-').next() {
117            self.current_year = y.to_string();
118        }
119    }
120}
121
122/// Known binary file extensions that should be copied without template rendering.
123const BINARY_EXTENSIONS: &[&str] = &[
124    "svg", "png", "jpg", "jpeg", "gif", "ico", "woff", "woff2", "ttf", "eot",
125];
126
127/// Check if a path has a binary extension.
128fn is_binary_path(path: &Path) -> bool {
129    path.extension()
130        .and_then(|ext| ext.to_str())
131        .is_some_and(|ext| BINARY_EXTENSIONS.contains(&ext.to_lowercase().as_str()))
132}
133
134/// Recursively collect all files from an embedded [`Dir`].
135fn walk_embedded_dir(dir: &'static Dir<'static>) -> Vec<&'static include_dir::File<'static>> {
136    let mut out = Vec::new();
137    for entry in dir.entries() {
138        match entry {
139            DirEntry::Dir(d) => out.extend(walk_embedded_dir(d)),
140            DirEntry::File(f) => out.push(f),
141        }
142    }
143    out
144}
145
146/// Collect embedded template files for the given directory stack (e.g. `["base", "panel", "wasm"]`).
147///
148/// Returns `(file_contents, relative_path)` pairs where the relative path is within each
149/// sub-directory (e.g. `src/module.ts.tera`, not `panel/src/module.ts.tera`).
150pub fn collect_template_files(dirs: &[&str]) -> Vec<(&'static [u8], PathBuf)> {
151    let mut files = Vec::new();
152    for dir_name in dirs {
153        let Some(dir) = TEMPLATES.get_dir(dir_name) else {
154            continue;
155        };
156        for file in walk_embedded_dir(dir) {
157            let rel = file.path().strip_prefix(dir_name).unwrap_or(file.path());
158            files.push((file.contents(), rel.to_path_buf()));
159        }
160    }
161    files
162}
163
164/// Render a template string with the given context.
165///
166/// # Errors
167///
168/// Returns an error when the template fails to parse or render.
169pub fn render_string(template: &str, context: &TemplateContext) -> Result<String> {
170    let mut tera = Tera::default();
171    tera.add_raw_template("__inline__", template)
172        .context("Failed to parse template")?;
173    let tera_ctx =
174        tera::Context::from_serialize(context).context("Failed to serialize template context")?;
175    tera.render("__inline__", &tera_ctx)
176        .context("Failed to render template")
177}
178
179/// Render embedded template content and write the result to `output_dir`.
180///
181/// The `.tera` suffix is stripped from `rel_path` in the output.
182///
183/// # Errors
184///
185/// Returns an error when the template fails to render or I/O fails.
186pub fn write_rendered(
187    contents: &[u8],
188    rel_path: &Path,
189    output_dir: &Path,
190    context: &TemplateContext,
191) -> Result<PathBuf> {
192    let out_rel = if rel_path.extension().and_then(|e| e.to_str()) == Some("tera") {
193        rel_path.with_extension("")
194    } else {
195        rel_path.to_path_buf()
196    };
197
198    let dest = output_dir.join(&out_rel);
199
200    if let Some(parent) = dest.parent() {
201        std::fs::create_dir_all(parent)
202            .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
203    }
204
205    let bytes = render_to_bytes(contents, rel_path, context)?;
206    std::fs::write(&dest, bytes)
207        .with_context(|| format!("Failed to write file: {}", dest.display()))?;
208
209    Ok(dest)
210}