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