Skip to main content

pitchfork_cli/
template.rs

1//! Tera template rendering for pitchfork.toml configuration fields.
2//!
3//! Allows `run`, `env` values, `hooks.*`, and `ready_cmd` to use Tera templates
4//! like `{{ daemons.redis.ports[0] }}` to reference computed values from other daemons.
5//!
6//! Templates are resolved level-by-level along the dependency order: each level
7//! can reference daemons from previous levels (which have already started and
8//! had their ports resolved).
9
10use crate::daemon_id::DaemonId;
11use crate::pitchfork_toml::PitchforkTomlDaemon;
12use crate::settings::settings;
13use indexmap::IndexMap;
14use std::collections::HashMap;
15use std::path::PathBuf;
16
17// ---------------------------------------------------------------------------
18// DaemonTemplateState
19// ---------------------------------------------------------------------------
20
21/// Resolved state of a daemon available for template rendering.
22#[derive(Debug, Clone)]
23pub struct DaemonTemplateState {
24    pub ports: Vec<u16>,
25    pub id: String,
26    pub name: String,
27    pub namespace: String,
28    pub slug: Option<String>,
29    pub dir: PathBuf,
30}
31
32impl DaemonTemplateState {
33    fn port(&self) -> Option<u16> {
34        self.ports.first().copied()
35    }
36}
37
38// ---------------------------------------------------------------------------
39// TemplateContext
40// ---------------------------------------------------------------------------
41
42/// Context for rendering Tera templates in pitchfork.toml fields.
43pub struct TemplateContext {
44    self_state: DaemonTemplateState,
45    daemon_states: HashMap<String, DaemonTemplateState>,
46}
47
48impl TemplateContext {
49    /// Build a template context for a daemon.
50    ///
51    /// - `id`: the daemon being rendered
52    /// - `daemon_config`: its pitchfork.toml config
53    /// - `resolved_daemons`: map of daemon ID -> resolved ports from previous levels
54    /// - `daemon_configs`: the full PitchforkToml.daemons map for looking up dir/slug
55    pub fn new(
56        id: &DaemonId,
57        daemon_config: &PitchforkTomlDaemon,
58        resolved_daemons: &HashMap<DaemonId, Vec<u16>>,
59        daemon_configs: &IndexMap<DaemonId, PitchforkTomlDaemon>,
60    ) -> Self {
61        let global_slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
62        let dir = crate::ipc::batch::resolve_daemon_dir(
63            daemon_config.dir.as_deref(),
64            daemon_config.path.as_deref(),
65        );
66
67        let self_state = DaemonTemplateState {
68            ports: Vec::new(),
69            id: id.qualified(),
70            name: id.name().to_string(),
71            namespace: id.namespace().to_string(),
72            slug: crate::pitchfork_toml::PitchforkToml::find_slug_for_daemon_in_registry(
73                id,
74                &global_slugs,
75            ),
76            dir,
77        };
78
79        let mut daemon_states = HashMap::new();
80        for (dep_id, ports) in resolved_daemons {
81            if let Some(config) = daemon_configs.get(dep_id) {
82                let dep_dir = crate::ipc::batch::resolve_daemon_dir(
83                    config.dir.as_deref(),
84                    config.path.as_deref(),
85                );
86                let state = DaemonTemplateState {
87                    ports: ports.clone(),
88                    id: dep_id.qualified(),
89                    name: dep_id.name().to_string(),
90                    namespace: dep_id.namespace().to_string(),
91                    slug: crate::pitchfork_toml::PitchforkToml::find_slug_for_daemon_in_registry(
92                        dep_id,
93                        &global_slugs,
94                    ),
95                    dir: dep_dir,
96                };
97
98                // Short names are only valid within the current namespace.
99                if dep_id.namespace() == id.namespace() {
100                    daemon_states.insert(dep_id.name().to_string(), state.clone());
101                }
102
103                // Register with qualified key (namespace.name) for all namespaces.
104                daemon_states.insert(qualified_key(dep_id), state);
105            }
106        }
107
108        Self {
109            self_state,
110            daemon_states,
111        }
112    }
113
114    /// Convert this context into a Tera Context for rendering.
115    pub fn to_tera_context(&self) -> tera::Context {
116        let mut ctx = tera::Context::new();
117
118        // Self variables
119        ctx.insert("name", &self.self_state.name);
120        ctx.insert("namespace", &self.self_state.namespace);
121        ctx.insert("id", &self.self_state.id);
122        ctx.insert("slug", &self.self_state.slug);
123        ctx.insert("dir", &self.self_state.dir.to_string_lossy().to_string());
124
125        // Daemons
126        let mut daemons_map = serde_json::Map::new();
127        for (name, state) in &self.daemon_states {
128            if daemons_map.contains_key(name) {
129                continue;
130            }
131            daemons_map.insert(name.clone(), daemon_state_to_json(state));
132        }
133        ctx.insert("daemons", &serde_json::Value::Object(daemons_map));
134
135        // Settings
136        let s = settings();
137        ctx.insert(
138            "settings",
139            &serde_json::json!({
140                "proxy": {
141                    "enable": s.proxy.enable,
142                    "tld": s.proxy.tld,
143                    "port": s.proxy.port,
144                    "https": s.proxy.https,
145                }
146            }),
147        );
148
149        // Always expose proxy_url so templates can distinguish an unroutable daemon
150        // via a strict null value instead of an undefined-variable error.
151        let proxy_url = build_proxy_url(self.self_state.slug.as_deref(), &s);
152        ctx.insert("proxy_url", &proxy_url);
153
154        ctx
155    }
156}
157
158fn daemon_state_to_json(state: &DaemonTemplateState) -> serde_json::Value {
159    serde_json::json!({
160        "port": state.port(),
161        "ports": state.ports,
162        "id": state.id,
163        "name": state.name,
164        "namespace": state.namespace,
165        "slug": state.slug,
166        "dir": state.dir.to_string_lossy(),
167    })
168}
169
170/// Convert a DaemonId into a template key using `namespace.name` format.
171/// E.g. `myproj/redis` -> `myproj.redis`
172fn qualified_key(id: &DaemonId) -> String {
173    format!("{}.{}", id.namespace(), id.name())
174}
175
176/// Build a proxy URL from slug and settings.
177fn build_proxy_url(slug: Option<&str>, s: &crate::settings::Settings) -> Option<String> {
178    let slug = slug?;
179    let scheme = if s.proxy.https { "https" } else { "http" };
180    let tld = &s.proxy.tld;
181    let standard_port = if s.proxy.https { 443u16 } else { 80u16 };
182    let effective_port = u16::try_from(s.proxy.port).ok().filter(|&p| p > 0)?;
183    let host = format!("{slug}.{tld}");
184    Some(if effective_port == standard_port {
185        format!("{scheme}://{host}")
186    } else {
187        format!("{scheme}://{host}:{effective_port}")
188    })
189}
190
191// ---------------------------------------------------------------------------
192// Rendering
193// ---------------------------------------------------------------------------
194
195/// Render a Tera template string with the given context.
196///
197/// Returns the rendered string, or an error describing what went wrong.
198/// Fast path: strings without `{{` or `{%` are returned as-is.
199pub fn render_template(template: &str, context: &TemplateContext) -> Result<String, RenderError> {
200    TemplateRenderer::new(context).render(template)
201}
202
203/// Render all template-enabled fields of a daemon config.
204///
205/// Modifies the config in place. Returns the first error encountered from
206/// non-hook fields (`run`, `env`, `ready_cmd`). Hook template errors are
207/// logged as warnings and the hook is set to `None` — hooks are re-rendered
208/// at fire time via `fire_hook`, so pre-rendered hook strings are unused.
209pub fn render_daemon_templates(
210    config: &mut PitchforkTomlDaemon,
211    context: &TemplateContext,
212) -> Result<(), RenderError> {
213    let mut renderer = TemplateRenderer::new(context);
214
215    config.run = renderer.render(&config.run)?;
216
217    if let Some(ref env) = config.env {
218        let rendered: IndexMap<String, String> = env
219            .iter()
220            .map(|(k, v)| Ok((k.clone(), renderer.render(v)?)))
221            .collect::<Result<_, RenderError>>()?;
222        config.env = Some(rendered);
223    }
224
225    if let Some(ref hooks) = config.hooks {
226        let rendered = crate::config_types::PitchforkTomlHooks {
227            on_ready: hooks
228                .on_ready
229                .as_deref()
230                .and_then(|t| renderer.render(t).ok()),
231            on_fail: hooks
232                .on_fail
233                .as_deref()
234                .and_then(|t| renderer.render(t).ok()),
235            on_retry: hooks
236                .on_retry
237                .as_deref()
238                .and_then(|t| renderer.render(t).ok()),
239            on_stop: hooks
240                .on_stop
241                .as_deref()
242                .and_then(|t| renderer.render(t).ok()),
243            on_exit: hooks
244                .on_exit
245                .as_deref()
246                .and_then(|t| renderer.render(t).ok()),
247            on_output: hooks.on_output.as_ref().and_then(|hook| {
248                renderer
249                    .render(&hook.run)
250                    .ok()
251                    .map(|run| crate::config_types::OnOutputHook {
252                        run,
253                        filter: hook.filter.clone(),
254                        regex: hook.regex.clone(),
255                        debounce: hook.debounce.clone(),
256                    })
257            }),
258        };
259        config.hooks = Some(rendered);
260    }
261
262    if let Some(ref cmd) = config.ready_cmd {
263        config.ready_cmd = Some(renderer.render(cmd)?);
264    }
265
266    Ok(())
267}
268
269fn contains_template_syntax(template: &str) -> bool {
270    template.contains("{{") || template.contains("{%") || template.contains("{#")
271}
272
273struct TemplateRenderer {
274    tera: tera::Tera,
275    context: tera::Context,
276    next_template_id: usize,
277}
278
279impl TemplateRenderer {
280    fn new(context: &TemplateContext) -> Self {
281        Self {
282            tera: tera::Tera::default(),
283            context: context.to_tera_context(),
284            next_template_id: 0,
285        }
286    }
287
288    fn render(&mut self, template: &str) -> Result<String, RenderError> {
289        if !contains_template_syntax(template) {
290            return Ok(template.to_string());
291        }
292
293        let template_name = format!("config_{}", self.next_template_id);
294        self.next_template_id += 1;
295
296        self.tera
297            .add_raw_template(&template_name, template)
298            .map_err(|e| RenderError::TemplateSyntax {
299                template: template.to_string(),
300                source: e,
301            })?;
302
303        self.tera
304            .render(&template_name, &self.context)
305            .map_err(|e| RenderError::RenderFailed {
306                template: template.to_string(),
307                source: e,
308            })
309    }
310}
311
312// ---------------------------------------------------------------------------
313// RenderError
314// ---------------------------------------------------------------------------
315
316#[derive(Debug, thiserror::Error)]
317pub enum RenderError {
318    #[error("template syntax error in {template:?}: {source}")]
319    TemplateSyntax {
320        template: String,
321        source: tera::Error,
322    },
323    #[error("template render failed for {template:?}: {source}")]
324    RenderFailed {
325        template: String,
326        source: tera::Error,
327    },
328}
329
330// ---------------------------------------------------------------------------
331// Tests
332// ---------------------------------------------------------------------------
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    fn make_daemon_config(run: &str) -> PitchforkTomlDaemon {
339        PitchforkTomlDaemon {
340            run: run.to_string(),
341            ..Default::default()
342        }
343    }
344
345    fn make_context_with_daemon(name: &str, ports: Vec<u16>) -> TemplateContext {
346        let id = DaemonId::new("myproj", name);
347        let config = make_daemon_config("echo");
348        let mut resolved = HashMap::new();
349        resolved.insert(id.clone(), ports);
350        let mut configs = IndexMap::new();
351        configs.insert(id.clone(), make_daemon_config("echo"));
352        TemplateContext::new(
353            &DaemonId::new("myproj", "self"),
354            &config,
355            &resolved,
356            &configs,
357        )
358    }
359
360    #[test]
361    fn test_no_template_passthrough() {
362        let ctx = make_context_with_daemon("redis", vec![6379]);
363        assert_eq!(render_template("hello world", &ctx).unwrap(), "hello world");
364    }
365
366    #[test]
367    fn test_self_variables() {
368        let id = DaemonId::new("myproj", "api");
369        let config = make_daemon_config("echo");
370        let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
371
372        assert_eq!(render_template("{{ name }}", &ctx).unwrap(), "api");
373        assert_eq!(render_template("{{ namespace }}", &ctx).unwrap(), "myproj");
374        assert_eq!(render_template("{{ id }}", &ctx).unwrap(), "myproj/api");
375    }
376
377    #[test]
378    fn test_daemon_port_reference() {
379        let ctx = make_context_with_daemon("redis", vec![6379]);
380        assert_eq!(
381            render_template("{{ daemons.redis.port }}", &ctx).unwrap(),
382            "6379"
383        );
384    }
385
386    #[test]
387    fn test_daemon_ports_array() {
388        let ctx = make_context_with_daemon("redis", vec![6379, 6380]);
389        assert_eq!(
390            render_template("{{ daemons.redis.ports[0] }}", &ctx).unwrap(),
391            "6379"
392        );
393        assert_eq!(
394            render_template("{{ daemons.redis.ports[1] }}", &ctx).unwrap(),
395            "6380"
396        );
397    }
398
399    #[test]
400    fn test_daemon_qualified_name() {
401        let ctx = make_context_with_daemon("redis", vec![6379]);
402        assert_eq!(
403            render_template("{{ daemons[\"myproj.redis\"].port }}", &ctx).unwrap(),
404            "6379"
405        );
406    }
407
408    #[test]
409    fn test_short_name_only_matches_current_namespace() {
410        let self_id = DaemonId::new("app", "api");
411        let self_config = make_daemon_config("echo");
412        let other_id = DaemonId::new("infra", "redis");
413
414        let mut resolved = HashMap::new();
415        resolved.insert(other_id.clone(), vec![6379]);
416
417        let mut configs = IndexMap::new();
418        configs.insert(other_id.clone(), make_daemon_config("echo"));
419
420        let ctx = TemplateContext::new(&self_id, &self_config, &resolved, &configs);
421
422        assert!(render_template("{{ daemons.redis.port }}", &ctx).is_err());
423        assert_eq!(
424            render_template("{{ daemons[\"infra.redis\"].port }}", &ctx).unwrap(),
425            "6379"
426        );
427    }
428
429    #[test]
430    fn test_settings_reference() {
431        let ctx = make_context_with_daemon("redis", vec![6379]);
432        let result = render_template("{{ settings.proxy.tld }}", &ctx).unwrap();
433        // Default TLD is "localhost"
434        assert_eq!(result, "localhost");
435    }
436
437    #[test]
438    fn test_undefined_variable_error() {
439        let ctx = make_context_with_daemon("redis", vec![6379]);
440        let result = render_template("{{ nonexistent }}", &ctx);
441        assert!(result.is_err());
442    }
443
444    #[test]
445    fn test_comment_only_template_is_parsed() {
446        let ctx = make_context_with_daemon("redis", vec![6379]);
447        assert_eq!(
448            render_template("before{# hidden #}after", &ctx).unwrap(),
449            "beforeafter"
450        );
451    }
452
453    #[test]
454    fn test_proxy_url_is_present_as_null_when_slug_is_missing() {
455        let id = DaemonId::new("myproj", "api");
456        let config = make_daemon_config("echo");
457        let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
458
459        assert_eq!(
460            render_template("{{ proxy_url | default(value=\"none\") }}", &ctx).unwrap(),
461            "none"
462        );
463    }
464
465    #[test]
466    fn test_mixed_template_and_literal() {
467        let ctx = make_context_with_daemon("redis", vec![6379]);
468        assert_eq!(
469            render_template("redis://localhost:{{ daemons.redis.port }}/0", &ctx).unwrap(),
470            "redis://localhost:6379/0"
471        );
472    }
473
474    #[test]
475    fn test_render_daemon_templates_run() {
476        let ctx = make_context_with_daemon("redis", vec![6379]);
477        let mut config = PitchforkTomlDaemon {
478            run: "redis-cli -p {{ daemons.redis.port }}".to_string(),
479            ..Default::default()
480        };
481        render_daemon_templates(&mut config, &ctx).unwrap();
482        assert_eq!(config.run, "redis-cli -p 6379");
483    }
484
485    #[test]
486    fn test_render_daemon_templates_env() {
487        let ctx = make_context_with_daemon("redis", vec![6379]);
488        let mut config = PitchforkTomlDaemon {
489            run: "echo".to_string(),
490            env: Some(IndexMap::from([
491                (
492                    "DATABASE_URL".to_string(),
493                    "redis://localhost:{{ daemons.redis.port }}/0".to_string(),
494                ),
495                ("STATIC_VAR".to_string(), "unchanged".to_string()),
496            ])),
497            ..Default::default()
498        };
499        render_daemon_templates(&mut config, &ctx).unwrap();
500        let env = config.env.unwrap();
501        assert_eq!(env["DATABASE_URL"], "redis://localhost:6379/0");
502        assert_eq!(env["STATIC_VAR"], "unchanged");
503    }
504
505    #[test]
506    fn test_render_daemon_templates_on_output_run() {
507        let ctx = make_context_with_daemon("redis", vec![6379]);
508        let mut config = PitchforkTomlDaemon {
509            run: "echo".to_string(),
510            hooks: Some(crate::config_types::PitchforkTomlHooks {
511                on_ready: None,
512                on_fail: None,
513                on_retry: None,
514                on_stop: None,
515                on_exit: None,
516                on_output: Some(crate::config_types::OnOutputHook {
517                    run: "curl http://localhost:{{ daemons.redis.port }}".to_string(),
518                    filter: Some("ready".to_string()),
519                    regex: None,
520                    debounce: None,
521                }),
522            }),
523            ..Default::default()
524        };
525
526        render_daemon_templates(&mut config, &ctx).unwrap();
527
528        let hooks = config.hooks.unwrap();
529        let on_output = hooks.on_output.unwrap();
530        assert_eq!(on_output.run, "curl http://localhost:6379");
531        assert_eq!(on_output.filter.as_deref(), Some("ready"));
532    }
533
534    #[test]
535    fn test_render_daemon_templates_hook_error_does_not_fail() {
536        let ctx = make_context_with_daemon("redis", vec![6379]);
537        let mut config = PitchforkTomlDaemon {
538            run: "echo".to_string(),
539            hooks: Some(crate::config_types::PitchforkTomlHooks {
540                on_ready: Some("{{ nonexistent }}".to_string()),
541                on_fail: None,
542                on_retry: None,
543                on_stop: None,
544                on_exit: None,
545                on_output: None,
546            }),
547            ..Default::default()
548        };
549
550        // Hook template errors are silently converted to None — daemon still starts
551        render_daemon_templates(&mut config, &ctx).unwrap();
552        let hooks = config.hooks.unwrap();
553        assert!(hooks.on_ready.is_none());
554    }
555
556    #[test]
557    fn test_render_daemon_templates_run_error_still_fails() {
558        let ctx = make_context_with_daemon("redis", vec![6379]);
559        let mut config = PitchforkTomlDaemon {
560            run: "{{ nonexistent }}".to_string(),
561            ..Default::default()
562        };
563
564        // Non-hook template errors still propagate as Err
565        assert!(render_daemon_templates(&mut config, &ctx).is_err());
566    }
567}