Skip to main content

memscope_rs/render_engine/dashboard/renderer/
template_registry.rs

1//! Template registry - manages dashboard template loading and selection
2//!
3//! This module provides a centralized registry for dashboard templates,
4//! supporting both built-in templates and external template files from
5//! the templetes/ directory.
6
7use 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
16/// Both rendering APIs share one immutable compiled built-in registry.
17pub(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/// Registered dashboard template
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct DashboardTemplate {
65    /// Unique template identifier
66    pub id: String,
67    /// Human-readable display name
68    pub name: String,
69    /// Description of what this template visualizes
70    pub description: String,
71    /// File path to the Handlebars HTML template
72    pub template_path: PathBuf,
73    /// Whether this is a built-in or external template
74    #[serde(default)]
75    pub kind: TemplateKind,
76}
77
78/// Template source classification
79#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80pub enum TemplateKind {
81    /// Built-in template bundled in the crate
82    #[default]
83    BuiltIn,
84    /// External template loaded from file system
85    External,
86}
87
88/// Template registry - holds all available dashboard templates
89pub struct TemplateRegistry {
90    /// Map of template ID to DashboardTemplate
91    templates: HashMap<String, DashboardTemplate>,
92    /// Handlebars instance with all templates registered
93    handlebars: Arc<Handlebars<'static>>,
94    /// Base directory for external templates
95    external_base: Option<PathBuf>,
96}
97
98impl TemplateRegistry {
99    /// Create a new empty template registry
100    pub fn new() -> Self {
101        let mut handlebars = Handlebars::new();
102        // Register all Handlebars helpers (format_bytes, eq, len, risk_class, ...)
103        // so templates rendered through this registry can use them. Without this,
104        // helper calls like {{len thread_policies}} would be misinterpreted as
105        // field accesses and fail with "Cannot access array/vector with string
106        // index" errors.
107        register_helpers(&mut handlebars);
108        Self {
109            templates: HashMap::new(),
110            handlebars: Arc::new(handlebars),
111            external_base: None,
112        }
113    }
114
115    /// Create a registry with the single built-in merged dashboard template pre-loaded.
116    ///
117    /// The merged template lives at
118    /// `src/render_engine/dashboard/templates/dashboard_unified.html` and bundles all
119    /// eight dashboard modes (Overview, Threads, Async, Task Graph, Variables,
120    /// Passports, FFI, Unsafe/Time) into one HTML file with a side-bar mode switcher.
121    /// The `templates_dir` argument is kept for API compatibility but no longer used.
122    pub fn with_built_in_templates(
123        templates_dir: &Path,
124    ) -> Result<Self, Box<dyn std::error::Error>> {
125        let _ = templates_dir; // unused: single merged template location is fixed
126        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    /// Set the base directory for external templates
151    pub fn set_external_base(&mut self, path: PathBuf) {
152        self.external_base = Some(path);
153    }
154
155    /// Load external templates from the templetes/ directory
156    ///
157    /// Scans subdirectories for code.html files and registers them.
158    /// Each subdirectory becomes a template with the directory name as the ID.
159    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            // Derive template ID from directory name (kebab-case → snake_case)
183            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            // Derive display name from directory name
190            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    /// Register a single template
222    fn register_template(
223        &mut self,
224        template: DashboardTemplate,
225    ) -> Result<(), Box<dyn std::error::Error>> {
226        let id = template.id.clone();
227        // External templates belong only to this registry. Clone on mutation,
228        // preserving the process-wide built-ins and other renderer instances.
229        Arc::make_mut(&mut self.handlebars).register_template_file(&id, &template.template_path)?;
230        self.templates.insert(id, template);
231        Ok(())
232    }
233
234    /// Get all available templates
235    pub fn templates(&self) -> &HashMap<String, DashboardTemplate> {
236        &self.templates
237    }
238
239    /// Get a specific template by ID
240    pub fn get_template(&self, id: &str) -> Option<&DashboardTemplate> {
241        self.templates.get(id)
242    }
243
244    /// Render a dashboard using the specified template
245    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    /// List all available template IDs
270    pub fn template_ids(&self) -> Vec<String> {
271        self.templates.keys().cloned().collect()
272    }
273
274    /// Count of available templates
275    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    /// Objective: Cached built-ins cannot leak externally loaded templates between instances.
291    /// Invariants: Independent registries retain their own template source and rendered data.
292    #[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        // Cannot fully test without a real file, but structure is verified
348    }
349}