Skip to main content

create_grafana_plugin/
template.rs

1//! Template discovery, rendering, and [`TemplateContext`] for scaffold and update flows.
2
3use anyhow::{Context, Result};
4use serde::Serialize;
5use std::path::{Path, PathBuf};
6use tera::Tera;
7use walkdir::WalkDir;
8
9use crate::config::ProjectConfig;
10
11/// Resolve the `templates/` directory next to the binary or from the workspace (dev).
12///
13/// # Errors
14///
15/// Returns an error when no templates directory can be located.
16pub fn templates_root() -> Result<PathBuf> {
17    let exe_dir = std::env::current_exe()
18        .ok()
19        .and_then(|p| p.parent().map(Path::to_path_buf));
20
21    if let Some(ref dir) = exe_dir {
22        let candidate = dir.join("templates");
23        if candidate.exists() {
24            return Ok(candidate);
25        }
26        if let Some(c) = dir
27            .parent()
28            .and_then(|p| p.parent())
29            .map(|p| p.join("templates"))
30            && c.exists()
31        {
32            return Ok(c);
33        }
34    }
35
36    let manifest_dir = env!("CARGO_MANIFEST_DIR");
37    let workspace_root = Path::new(manifest_dir)
38        .parent()
39        .unwrap_or_else(|| Path::new("."));
40    let candidate = workspace_root.join("templates");
41    if candidate.exists() {
42        return Ok(candidate);
43    }
44
45    anyhow::bail!("Could not find templates directory")
46}
47
48/// Render a template file to bytes (same rules as [`render_file`] but without writing).
49///
50/// # Errors
51///
52/// Returns an error when the template cannot be read or rendered.
53pub fn render_template_to_bytes(
54    src: &Path,
55    rel_path: &Path,
56    context: &TemplateContext,
57) -> Result<Vec<u8>> {
58    if is_binary_file(src) || rel_path.extension().and_then(|e| e.to_str()) != Some("tera") {
59        std::fs::read(src).with_context(|| format!("Failed to read file: {}", src.display()))
60    } else {
61        let template_body = std::fs::read_to_string(src)
62            .with_context(|| format!("Failed to read template: {}", src.display()))?;
63        let rendered = render_string(&template_body, context)?;
64        Ok(rendered.into_bytes())
65    }
66}
67
68/// Template rendering context with user config + computed fields
69#[derive(Debug, Serialize)]
70pub struct TemplateContext {
71    // User config
72    pub plugin_name: String,
73    pub plugin_description: String,
74    pub author: String,
75    pub org: String,
76    pub plugin_type: String,
77    pub has_wasm: bool,
78    pub has_docker: bool,
79    pub has_mock: bool,
80
81    // Computed fields
82    pub plugin_id: String,
83    pub crate_name: String,
84    pub current_year: String,
85    pub today: String,
86    pub pascal_case_name: String,
87}
88
89impl TemplateContext {
90    /// Build context from project config with computed fields
91    pub fn from_config(config: &ProjectConfig) -> Self {
92        let plugin_id = format!("{}-{}", config.org, config.name);
93        let crate_name = config.name.replace('-', "_");
94        let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
95        let year = chrono::Utc::now().format("%Y").to_string();
96
97        let pascal_case_name = config
98            .name
99            .split('-')
100            .map(|word| {
101                let mut chars = word.chars();
102                match chars.next() {
103                    None => String::new(),
104                    Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
105                }
106            })
107            .collect::<String>();
108
109        Self {
110            plugin_name: config.name.clone(),
111            plugin_description: config.description.clone(),
112            author: config.author.clone(),
113            org: config.org.clone(),
114            plugin_type: config.plugin_type.to_string(),
115            has_wasm: config.has_wasm,
116            has_docker: config.has_docker,
117            has_mock: config.has_mock,
118            plugin_id,
119            crate_name,
120            current_year: year,
121            today,
122            pascal_case_name,
123        }
124    }
125
126    /// Prefer `plugin.json` `info.updated` and derived year so updates do not churn dates.
127    pub fn apply_dates_from_existing_plugin_json(&mut self, project_dir: &Path) {
128        let plugin_path = project_dir.join("plugin.json");
129        let Ok(raw) = std::fs::read_to_string(&plugin_path) else {
130            return;
131        };
132        let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
133            return;
134        };
135        let Some(u) = v
136            .get("info")
137            .and_then(|info| info.get("updated"))
138            .and_then(|x| x.as_str())
139        else {
140            return;
141        };
142        self.today = u.to_string();
143        if let Some(y) = u.split('-').next() {
144            self.current_year = y.to_string();
145        }
146    }
147}
148
149/// Known binary file extensions that should be copied without template rendering
150const BINARY_EXTENSIONS: &[&str] = &[
151    "svg", "png", "jpg", "jpeg", "gif", "ico", "woff", "woff2", "ttf", "eot",
152];
153
154/// Check if a file should be treated as binary
155fn is_binary_file(path: &Path) -> bool {
156    path.extension()
157        .and_then(|ext| ext.to_str())
158        .is_some_and(|ext| BINARY_EXTENSIONS.contains(&ext.to_lowercase().as_str()))
159}
160
161/// Collect template files from specified directories under `templates_root`
162pub fn collect_template_dirs(templates_root: &Path, dirs: &[&str]) -> Vec<(PathBuf, PathBuf)> {
163    let mut files = Vec::new();
164    for dir_name in dirs {
165        let dir = templates_root.join(dir_name);
166        if !dir.exists() {
167            continue;
168        }
169        for entry in WalkDir::new(&dir).into_iter().filter_map(Result::ok) {
170            if entry.file_type().is_file() {
171                let rel = entry.path().strip_prefix(&dir).unwrap_or(entry.path());
172                files.push((entry.path().to_path_buf(), rel.to_path_buf()));
173            }
174        }
175    }
176    files
177}
178
179/// Render a template string with the given context.
180///
181/// # Errors
182///
183/// Returns an error when the template fails to parse or render.
184pub fn render_string(template: &str, context: &TemplateContext) -> Result<String> {
185    let mut tera = Tera::default();
186    tera.add_raw_template("__inline__", template)
187        .context("Failed to parse template")?;
188    let tera_ctx =
189        tera::Context::from_serialize(context).context("Failed to serialize template context")?;
190    tera.render("__inline__", &tera_ctx)
191        .context("Failed to render template")
192}
193
194/// Render a template file, stripping the `.tera` extension from output path.
195///
196/// # Errors
197///
198/// Returns an error when templates cannot be read or written.
199pub fn render_file(
200    src: &Path,
201    output_dir: &Path,
202    rel_path: &Path,
203    context: &TemplateContext,
204) -> Result<PathBuf> {
205    // Strip .tera extension from output path
206    let out_rel = if rel_path.extension().and_then(|e| e.to_str()) == Some("tera") {
207        rel_path.with_extension("")
208    } else {
209        rel_path.to_path_buf()
210    };
211
212    let dest = output_dir.join(&out_rel);
213
214    // Create parent directory
215    if let Some(parent) = dest.parent() {
216        std::fs::create_dir_all(parent)
217            .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
218    }
219
220    if is_binary_file(src) || (rel_path.extension().and_then(|e| e.to_str()) != Some("tera")) {
221        // Copy binary files or non-tera files as-is
222        std::fs::copy(src, &dest)
223            .with_context(|| format!("Failed to copy file: {}", src.display()))?;
224    } else {
225        // Render template
226        let template_body = std::fs::read_to_string(src)
227            .with_context(|| format!("Failed to read template: {}", src.display()))?;
228        let rendered = render_string(&template_body, context)?;
229        std::fs::write(&dest, rendered)
230            .with_context(|| format!("Failed to write file: {}", dest.display()))?;
231    }
232
233    Ok(dest)
234}