memscope_rs/render_engine/dashboard/renderer/
template_registry.rs1use handlebars::Handlebars;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, OnceLock};
13
14use super::helpers::register_helpers;
15
16pub(super) fn builtin_handlebars(
18) -> Result<Arc<Handlebars<'static>>, Arc<handlebars::TemplateError>> {
19 static BUILTIN: OnceLock<Result<Arc<Handlebars<'static>>, Arc<handlebars::TemplateError>>> =
20 OnceLock::new();
21 BUILTIN
22 .get_or_init(|| {
23 let mut handlebars = Handlebars::new();
24 register_helpers(&mut handlebars);
25 for (name, source) in [
26 (
27 "dashboard_modes",
28 include_str!("../templates/partials/modes.hbs"),
29 ),
30 (
31 "dashboard_runtime",
32 include_str!("../templates/partials/runtime.hbs"),
33 ),
34 (
35 "dashboard_charts",
36 include_str!("../templates/partials/charts.hbs"),
37 ),
38 (
39 "dashboard_graphs",
40 include_str!("../templates/partials/graphs.hbs"),
41 ),
42 (
43 "dashboard_interactions",
44 include_str!("../templates/partials/interactions.hbs"),
45 ),
46 ] {
47 handlebars
48 .register_template_string(name, source)
49 .map_err(Arc::new)?;
50 }
51 handlebars
52 .register_template_string(
53 "dashboard_unified",
54 include_str!("../templates/dashboard_unified.html"),
55 )
56 .map_err(Arc::new)?;
57 Ok(Arc::new(handlebars))
58 })
59 .clone()
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct DashboardTemplate {
65 pub id: String,
67 pub name: String,
69 pub description: String,
71 pub template_path: PathBuf,
73 #[serde(default)]
75 pub kind: TemplateKind,
76}
77
78#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80pub enum TemplateKind {
81 #[default]
83 BuiltIn,
84 External,
86}
87
88pub struct TemplateRegistry {
90 templates: HashMap<String, DashboardTemplate>,
92 handlebars: Arc<Handlebars<'static>>,
94 external_base: Option<PathBuf>,
96}
97
98impl TemplateRegistry {
99 pub fn new() -> Self {
101 let mut handlebars = Handlebars::new();
102 register_helpers(&mut handlebars);
108 Self {
109 templates: HashMap::new(),
110 handlebars: Arc::new(handlebars),
111 external_base: None,
112 }
113 }
114
115 pub fn with_built_in_templates(
123 templates_dir: &Path,
124 ) -> Result<Self, Box<dyn std::error::Error>> {
125 let _ = templates_dir; let mut registry = Self {
127 templates: HashMap::new(),
128 handlebars: builtin_handlebars()?,
129 external_base: None,
130 };
131
132 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
133 let unified_path = manifest_dir
134 .join("src")
135 .join("render_engine")
136 .join("dashboard")
137 .join("templates")
138 .join("dashboard_unified.html");
139 registry.templates.insert("dashboard_unified".into(), DashboardTemplate {
140 id: "dashboard_unified".to_string(),
141 name: "Unified Dashboard".to_string(),
142 description: "Merged multi-mode dashboard (Overview, Threads, Async, Task Graph, Variables, Passports, FFI, Unsafe/Time)".to_string(),
143 template_path: unified_path,
144 kind: TemplateKind::BuiltIn,
145 });
146
147 Ok(registry)
148 }
149
150 pub fn set_external_base(&mut self, path: PathBuf) {
152 self.external_base = Some(path);
153 }
154
155 pub fn load_external_templates(
160 &mut self,
161 templetes_dir: &Path,
162 ) -> Result<(), Box<dyn std::error::Error>> {
163 if !templetes_dir.exists() {
164 tracing::warn!("Templetes directory not found: {:?}", templetes_dir);
165 return Ok(());
166 }
167
168 let entries = fs::read_dir(templetes_dir)?;
169 for entry in entries {
170 let entry = entry?;
171 let dir_path = entry.path();
172
173 if !dir_path.is_dir() {
174 continue;
175 }
176
177 let code_html = dir_path.join("code.html");
178 if !code_html.exists() {
179 continue;
180 }
181
182 let dir_name = dir_path
184 .file_name()
185 .and_then(|n| n.to_str())
186 .unwrap_or("unknown");
187 let id = dir_name.replace('-', "_").replace(" ", "_");
188
189 let name = dir_name
191 .split(['_', '-'])
192 .filter(|s| !s.is_empty())
193 .map(|s| {
194 s.chars().next().unwrap_or(' ').to_uppercase().to_string()
195 + &s[1..].to_lowercase()
196 })
197 .collect::<Vec<_>>()
198 .join(" ");
199
200 self.register_template(DashboardTemplate {
201 id: format!("ext_{}", id),
202 name: format!("Professional: {}", name),
203 description: format!("External template: {}", name),
204 template_path: code_html,
205 kind: TemplateKind::External,
206 })?;
207 }
208
209 tracing::info!(
210 "Loaded {} external templates from {:?}",
211 self.templates
212 .iter()
213 .filter(|(_, t)| t.kind == TemplateKind::External)
214 .count(),
215 templetes_dir
216 );
217
218 Ok(())
219 }
220
221 fn register_template(
223 &mut self,
224 template: DashboardTemplate,
225 ) -> Result<(), Box<dyn std::error::Error>> {
226 let id = template.id.clone();
227 Arc::make_mut(&mut self.handlebars).register_template_file(&id, &template.template_path)?;
230 self.templates.insert(id, template);
231 Ok(())
232 }
233
234 pub fn templates(&self) -> &HashMap<String, DashboardTemplate> {
236 &self.templates
237 }
238
239 pub fn get_template(&self, id: &str) -> Option<&DashboardTemplate> {
241 self.templates.get(id)
242 }
243
244 pub fn render(
246 &self,
247 template_id: &str,
248 data: &serde_json::Value,
249 ) -> Result<String, Box<dyn std::error::Error>> {
250 let template = self.templates.get(template_id).ok_or_else(|| {
251 format!(
252 "Template '{}' not found. Available: {:?}",
253 template_id,
254 self.template_ids()
255 )
256 })?;
257
258 let result = self.handlebars.render(template_id, data);
259 match result {
260 Ok(html) => Ok(html),
261 Err(e) => Err(format!(
262 "Failed to render template '{}': {} ({:?})",
263 template.name, e, template.template_path
264 )
265 .into()),
266 }
267 }
268
269 pub fn template_ids(&self) -> Vec<String> {
271 self.templates.keys().cloned().collect()
272 }
273
274 pub fn count(&self) -> usize {
276 self.templates.len()
277 }
278}
279
280impl Default for TemplateRegistry {
281 fn default() -> Self {
282 Self::new()
283 }
284}
285
286#[cfg(test)]
287mod cache_tests {
288 use super::*;
289
290 #[test]
293 fn external_templates_remain_instance_local() {
294 let dir = tempfile::tempdir().expect("external fixture directory");
295 let custom = dir.path().join("custom");
296 fs::create_dir(&custom).expect("external template directory");
297 let path = custom.join("code.html");
298 fs::write(&path, "first: {{value}}").expect("write first template");
299 let mut first = TemplateRegistry::with_built_in_templates(dir.path()).expect("built-ins");
300 first
301 .load_external_templates(dir.path())
302 .expect("load first template");
303 fs::write(&path, "second: {{value}}").expect("update external template");
304 let mut second = TemplateRegistry::with_built_in_templates(dir.path()).expect("built-ins");
305 second
306 .load_external_templates(dir.path())
307 .expect("reload updated template");
308 let data = serde_json::json!({"value":42});
309 assert_eq!(
310 first.render("ext_custom", &data).unwrap(),
311 "first: 42",
312 "first instance retains its source"
313 );
314 assert_eq!(
315 second.render("ext_custom", &data).unwrap(),
316 "second: 42",
317 "new instance observes external edits"
318 );
319 let builtin = builtin_handlebars().expect("compiled built-ins");
320 assert!(
321 !builtin.has_template("ext_custom"),
322 "external writes cannot mutate shared built-ins"
323 );
324 assert!(
325 builtin.has_template("dashboard_unified"),
326 "embedded dashboard is always available"
327 );
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 #[test]
336 fn test_registry_creation() {
337 let registry = TemplateRegistry::new();
338 assert_eq!(registry.count(), 0);
339 assert!(registry.template_ids().is_empty());
340 }
341
342 #[test]
343 fn test_register_and_retrieve() {
344 let registry = TemplateRegistry::new();
345 let templates = registry.templates();
346 assert!(templates.is_empty());
347 }
349}