Skip to main content

pitchfork_cli/
template.rs

1//! Tera template rendering for pitchfork.toml configuration fields.
2//!
3//! Allows `run`, `env` values, `hooks.*`, the readiness fields (`ready_cmd`,
4//! `ready_http`, `ready_port`, `ready_output`), and the health check fields
5//! (`health_cmd`, `health_http`, `health_port`) to use Tera templates like
6//! `{{ daemons.redis.ports[0] }}` to reference computed values from other daemons.
7//!
8//! Templates are resolved level-by-level along the dependency order: each level
9//! can reference daemons from previous levels (which have already started and
10//! had their ports resolved).
11
12use crate::daemon_id::DaemonId;
13use crate::pitchfork_toml::PitchforkTomlDaemon;
14use crate::settings::settings;
15use indexmap::IndexMap;
16use std::collections::HashMap;
17use std::path::PathBuf;
18
19// ---------------------------------------------------------------------------
20// DaemonTemplateState
21// ---------------------------------------------------------------------------
22
23/// Resolved state of a daemon available for template rendering.
24#[derive(Debug, Clone)]
25pub struct DaemonTemplateState {
26    pub ports: Vec<u16>,
27    pub id: String,
28    pub name: String,
29    pub namespace: String,
30    pub slug: Option<String>,
31    pub dir: PathBuf,
32}
33
34impl DaemonTemplateState {
35    fn port(&self) -> Option<u16> {
36        self.ports.first().copied()
37    }
38}
39
40// ---------------------------------------------------------------------------
41// TemplateContext
42// ---------------------------------------------------------------------------
43
44/// Context for rendering Tera templates in pitchfork.toml fields.
45pub struct TemplateContext {
46    self_state: DaemonTemplateState,
47    daemon_states: HashMap<String, DaemonTemplateState>,
48    /// Rendered environment variables for the current daemon, exposed as `env`
49    /// in templates (e.g. `{{ env.GRAM_HOST }}`). Set via [`set_env`] after env
50    /// values have been rendered, so it is `None` during env-value rendering
51    /// itself (preventing self-reference cycles).
52    env: Option<IndexMap<String, String>>,
53}
54
55impl TemplateContext {
56    /// Build a template context for a daemon.
57    ///
58    /// - `id`: the daemon being rendered
59    /// - `daemon_config`: its pitchfork.toml config
60    /// - `resolved_daemons`: map of daemon ID -> resolved ports from previous levels
61    /// - `daemon_configs`: the full PitchforkToml.daemons map for looking up dir/slug
62    pub fn new(
63        id: &DaemonId,
64        daemon_config: &PitchforkTomlDaemon,
65        resolved_daemons: &HashMap<DaemonId, Vec<u16>>,
66        daemon_configs: &IndexMap<DaemonId, PitchforkTomlDaemon>,
67    ) -> Self {
68        let global_slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
69        let effective_user = daemon_config.effective_user();
70        let dir = crate::ipc::batch::resolve_daemon_dir(
71            daemon_config.dir.as_deref(),
72            daemon_config.path.as_deref(),
73            effective_user.as_deref(),
74        );
75
76        let self_state = DaemonTemplateState {
77            ports: Vec::new(),
78            id: id.qualified(),
79            name: id.name().to_string(),
80            namespace: id.namespace().to_string(),
81            slug: crate::pitchfork_toml::PitchforkToml::find_slug_for_daemon_in_registry(
82                id,
83                &global_slugs,
84            ),
85            dir,
86        };
87
88        let mut daemon_states = HashMap::new();
89        for (dep_id, ports) in resolved_daemons {
90            if let Some(config) = daemon_configs.get(dep_id) {
91                let dep_effective_user = config.effective_user();
92                let dep_dir = crate::ipc::batch::resolve_daemon_dir(
93                    config.dir.as_deref(),
94                    config.path.as_deref(),
95                    dep_effective_user.as_deref(),
96                );
97                let state = DaemonTemplateState {
98                    ports: ports.clone(),
99                    id: dep_id.qualified(),
100                    name: dep_id.name().to_string(),
101                    namespace: dep_id.namespace().to_string(),
102                    slug: crate::pitchfork_toml::PitchforkToml::find_slug_for_daemon_in_registry(
103                        dep_id,
104                        &global_slugs,
105                    ),
106                    dir: dep_dir,
107                };
108
109                // Short names are only valid within the current namespace.
110                if dep_id.namespace() == id.namespace() {
111                    daemon_states.insert(dep_id.name().to_string(), state.clone());
112                }
113
114                // Register with qualified key (namespace.name) for all namespaces.
115                daemon_states.insert(qualified_key(dep_id), state);
116            }
117        }
118
119        Self {
120            self_state,
121            daemon_states,
122            env: None,
123        }
124    }
125
126    /// Set the rendered environment variables to expose as `env` in templates.
127    ///
128    /// Call this **after** rendering env values so that other fields (`run`,
129    /// `hooks`, `ready_*`) can reference `{{ env.X }}` with the final value.
130    pub fn set_env(&mut self, env: IndexMap<String, String>) {
131        self.env = Some(env);
132    }
133
134    /// Convert this context into a Tera Context for rendering.
135    pub fn to_tera_context(&self) -> tera::Context {
136        let mut ctx = tera::Context::new();
137
138        // Self variables
139        ctx.insert("name", &self.self_state.name);
140        ctx.insert("namespace", &self.self_state.namespace);
141        ctx.insert("id", &self.self_state.id);
142        ctx.insert("slug", &self.self_state.slug);
143        ctx.insert("dir", &self.self_state.dir.to_string_lossy().to_string());
144
145        // Daemons
146        let mut daemons_map = serde_json::Map::new();
147        for (name, state) in &self.daemon_states {
148            if daemons_map.contains_key(name) {
149                continue;
150            }
151            daemons_map.insert(name.clone(), daemon_state_to_json(state));
152        }
153        ctx.insert("daemons", &serde_json::Value::Object(daemons_map));
154
155        // Settings
156        let s = settings();
157        ctx.insert(
158            "settings",
159            &serde_json::json!({
160                "proxy": {
161                    "enable": s.proxy.enable,
162                    "tld": s.proxy.tld,
163                    "port": s.proxy.port,
164                    "https": s.proxy.https,
165                }
166            }),
167        );
168
169        // Always expose proxy_url so templates can distinguish an unroutable daemon
170        // via a strict null value instead of an undefined-variable error.
171        let proxy_url = build_proxy_url(self.self_state.slug.as_deref(), &s);
172        ctx.insert("proxy_url", &proxy_url);
173
174        // Rendered env for this daemon (set via set_env after env rendering)
175        if let Some(ref env) = self.env {
176            let map: serde_json::Map<String, serde_json::Value> = env
177                .iter()
178                .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
179                .collect();
180            ctx.insert("env", &serde_json::Value::Object(map));
181        }
182
183        ctx
184    }
185}
186
187fn daemon_state_to_json(state: &DaemonTemplateState) -> serde_json::Value {
188    serde_json::json!({
189        "port": state.port(),
190        "ports": state.ports,
191        "id": state.id,
192        "name": state.name,
193        "namespace": state.namespace,
194        "slug": state.slug,
195        "dir": state.dir.to_string_lossy(),
196    })
197}
198
199/// Convert a DaemonId into a template key using `namespace.name` format.
200/// E.g. `myproj/redis` -> `myproj.redis`
201fn qualified_key(id: &DaemonId) -> String {
202    format!("{}.{}", id.namespace(), id.name())
203}
204
205/// Build a proxy URL from slug and settings.
206fn build_proxy_url(slug: Option<&str>, s: &crate::settings::Settings) -> Option<String> {
207    let slug = slug?;
208    let scheme = if s.proxy.https { "https" } else { "http" };
209    let tld = &s.proxy.tld;
210    let standard_port = if s.proxy.https { 443u16 } else { 80u16 };
211    let effective_port = u16::try_from(s.proxy.port).ok().filter(|&p| p > 0)?;
212    let host = format!("{slug}.{tld}");
213    Some(if effective_port == standard_port {
214        format!("{scheme}://{host}")
215    } else {
216        format!("{scheme}://{host}:{effective_port}")
217    })
218}
219
220// ---------------------------------------------------------------------------
221// Env merge + render helpers
222// ---------------------------------------------------------------------------
223
224/// Merge top-level env with per-daemon env. Per-daemon values win on conflicts.
225pub(crate) fn merge_env(
226    top: Option<&IndexMap<String, String>>,
227    daemon: Option<&IndexMap<String, String>>,
228) -> Option<IndexMap<String, String>> {
229    match (top, daemon) {
230        (None, None) => None,
231        (Some(t), None) => Some(t.clone()),
232        (None, Some(d)) => Some(d.clone()),
233        (Some(t), Some(d)) => {
234            let mut merged = t.clone();
235            for (k, v) in d {
236                merged.insert(k.clone(), v.clone());
237            }
238            Some(merged)
239        }
240    }
241}
242
243/// Merge top-level and per-daemon env, render template values, and return the
244/// rendered env. Used by hook rendering where the daemon config is not mutated.
245///
246/// Env values are rendered with a context that does **not** include `env`
247/// itself, preventing self-reference cycles. Callers that want `{{ env.X }}`
248/// available in subsequent rendering should set the result on the context via
249/// [`TemplateContext::set_env`].
250pub fn render_env(
251    top_env: Option<&IndexMap<String, String>>,
252    daemon_env: Option<&IndexMap<String, String>>,
253    context: &TemplateContext,
254) -> Result<Option<IndexMap<String, String>>, RenderError> {
255    let Some(merged) = merge_env(top_env, daemon_env) else {
256        return Ok(None);
257    };
258    let mut renderer = TemplateRenderer::new(context);
259    let rendered: IndexMap<String, String> = merged
260        .iter()
261        .map(|(k, v)| Ok((k.clone(), renderer.render(v)?)))
262        .collect::<Result<_, _>>()?;
263    Ok(Some(rendered))
264}
265
266// ---------------------------------------------------------------------------
267// Rendering
268// ---------------------------------------------------------------------------
269
270/// Render a Tera template string with the given context.
271///
272/// Returns the rendered string, or an error describing what went wrong.
273/// Fast path: strings without `{{` or `{%` are returned as-is.
274pub fn render_template(template: &str, context: &TemplateContext) -> Result<String, RenderError> {
275    TemplateRenderer::new(context).render(template)
276}
277
278/// Render all template-enabled fields of a daemon config.
279///
280/// Top-level `env` (from `[env]` in pitchfork.toml) is merged into the daemon's
281/// own `env` as defaults — per-daemon values win on key conflicts. Env values
282/// are rendered first (with a context that excludes `env` itself, preventing
283/// self-reference cycles), then the rendered env is exposed as `{{ env.X }}`
284/// for the remaining fields (`run`, `hooks`, `ready_*`).
285///
286/// Modifies the config in place. Returns the first error encountered from
287/// non-hook fields (`run`, `env`, `ready_*`). Hook template errors are
288/// logged as warnings and the hook is set to `None` — hooks are re-rendered
289/// at fire time via `fire_hook`, so pre-rendered hook strings are unused.
290pub fn render_daemon_templates(
291    config: &mut PitchforkTomlDaemon,
292    context: &mut TemplateContext,
293    top_env: Option<&IndexMap<String, String>>,
294) -> Result<(), RenderError> {
295    // Phase 1: merge top-level env into the daemon's env (per-daemon wins) and
296    // render env values. `env` is NOT yet in the context, so env values cannot
297    // reference {{ env.* }} (preventing cycles). render_env builds its own
298    // short-lived renderer without env in scope.
299    let rendered_env = render_env(top_env, config.env.as_ref(), context)?;
300    config.env = rendered_env;
301
302    // Phase 2: expose the rendered env on the context as the authoritative
303    // state, so to_tera_context() (used by TemplateRenderer::new) includes it.
304    if let Some(ref env) = config.env {
305        context.set_env(env.clone());
306    }
307
308    let mut renderer = TemplateRenderer::new(context);
309
310    config.run = renderer.render(&config.run)?;
311
312    if let Some(ref hooks) = config.hooks {
313        let rendered = crate::config_types::PitchforkTomlHooks {
314            on_ready: hooks
315                .on_ready
316                .as_deref()
317                .and_then(|t| renderer.render(t).ok()),
318            on_fail: hooks
319                .on_fail
320                .as_deref()
321                .and_then(|t| renderer.render(t).ok()),
322            on_retry: hooks
323                .on_retry
324                .as_deref()
325                .and_then(|t| renderer.render(t).ok()),
326            on_stop: hooks
327                .on_stop
328                .as_deref()
329                .and_then(|t| renderer.render(t).ok()),
330            on_exit: hooks
331                .on_exit
332                .as_deref()
333                .and_then(|t| renderer.render(t).ok()),
334            on_output: hooks.on_output.as_ref().and_then(|hook| {
335                renderer
336                    .render(&hook.run)
337                    .ok()
338                    .map(|run| crate::config_types::OnOutputHook {
339                        run,
340                        filter: hook.filter.clone(),
341                        regex: hook.regex.clone(),
342                        debounce: hook.debounce.clone(),
343                    })
344            }),
345        };
346        config.hooks = Some(rendered);
347    }
348
349    if let Some(ref cmd) = config.ready_cmd {
350        config.ready_cmd = Some(crate::pitchfork_toml::ReadyCmd {
351            run: renderer.render(&cmd.run)?,
352            timeout: cmd.timeout,
353        });
354    }
355
356    if let Some(ref hc) = config.health_cmd {
357        config.health_cmd = Some(crate::pitchfork_toml::HealthCmd {
358            run: renderer.render(&hc.run)?,
359            interval: hc.interval,
360            timeout: hc.timeout,
361            retries: hc.retries,
362        });
363    }
364
365    if let Some(ref http) = config.health_http {
366        let mut http = http.clone();
367        http.url = renderer.render(&http.url)?;
368        config.health_http = Some(http);
369    }
370
371    if let Some(ref health_port) = config.health_port
372        && let Some(ref template) = health_port.template
373    {
374        let rendered = renderer.render(template)?;
375        let port = rendered
376            .trim()
377            .parse::<u16>()
378            .ok()
379            .filter(|&p| p > 0)
380            .ok_or_else(|| RenderError::InvalidPort {
381                field: "health_port",
382                template: template.clone(),
383                rendered: rendered.clone(),
384            })?;
385        config.health_port = Some(crate::config_types::HealthPort {
386            port: Some(port),
387            template: None,
388            interval: health_port.interval,
389            retries: health_port.retries,
390            timeout: health_port.timeout,
391        });
392    }
393
394    if let Some(ref output) = config.ready_output {
395        let pattern = renderer.render(&output.pattern)?;
396        config.ready_output = Some(crate::config_types::ReadyOutput {
397            pattern,
398            timeout: output.timeout,
399        });
400    }
401
402    if let Some(ref http) = config.ready_http {
403        let mut http = http.clone();
404        http.url = renderer.render(&http.url)?;
405        config.ready_http = Some(http);
406    }
407
408    if let Some(ref ready_port) = config.ready_port
409        && let Some(ref template) = ready_port.template
410    {
411        let rendered = renderer.render(template)?;
412        let port = rendered
413            .trim()
414            .parse::<u16>()
415            .ok()
416            .filter(|&p| p > 0)
417            .ok_or_else(|| RenderError::InvalidPort {
418                field: "ready_port",
419                template: template.clone(),
420                rendered: rendered.clone(),
421            })?;
422        config.ready_port = Some(crate::config_types::ReadyPort {
423            port: Some(port),
424            template: None,
425            timeout: ready_port.timeout,
426        });
427    }
428
429    Ok(())
430}
431
432fn contains_template_syntax(template: &str) -> bool {
433    template.contains("{{") || template.contains("{%") || template.contains("{#")
434}
435
436struct TemplateRenderer {
437    tera: tera::Tera,
438    context: tera::Context,
439    next_template_id: usize,
440}
441
442impl TemplateRenderer {
443    fn new(context: &TemplateContext) -> Self {
444        let mut tera = tera::Tera::default();
445        tera.register_filter(
446            "default",
447            |value: &tera::Value,
448             kwargs: tera::Kwargs,
449             _: &tera::State|
450             -> tera::TeraResult<tera::Value> {
451                let default_val = kwargs.must_get::<tera::Value>("value")?;
452                let boolean = kwargs.get::<bool>("boolean")?.unwrap_or_default();
453                if value.is_undefined() || value.is_none() || (boolean && !value.is_truthy()) {
454                    Ok(default_val)
455                } else {
456                    Ok(value.clone())
457                }
458            },
459        );
460        Self {
461            tera,
462            context: context.to_tera_context(),
463            next_template_id: 0,
464        }
465    }
466
467    fn render(&mut self, template: &str) -> Result<String, RenderError> {
468        if !contains_template_syntax(template) {
469            return Ok(template.to_string());
470        }
471
472        let template_name = format!("config_{}", self.next_template_id);
473        self.next_template_id += 1;
474
475        self.tera
476            .add_raw_template(&template_name, template)
477            .map_err(|e| RenderError::TemplateSyntax {
478                template: template.to_string(),
479                source: e,
480            })?;
481
482        self.tera
483            .render(&template_name, &self.context)
484            .map_err(|e| RenderError::RenderFailed {
485                template: template.to_string(),
486                source: e,
487            })
488    }
489}
490
491// ---------------------------------------------------------------------------
492// RenderError
493// ---------------------------------------------------------------------------
494
495#[derive(Debug, thiserror::Error)]
496pub enum RenderError {
497    #[error("template syntax error in {template:?}: {source}")]
498    TemplateSyntax {
499        template: String,
500        source: tera::Error,
501    },
502    #[error("template render failed for {template:?}: {source}")]
503    RenderFailed {
504        template: String,
505        source: tera::Error,
506    },
507    #[error(
508        "{field} template {template:?} rendered to {rendered:?}, expected a port number (1-65535)"
509    )]
510    InvalidPort {
511        field: &'static str,
512        template: String,
513        rendered: String,
514    },
515}
516
517// ---------------------------------------------------------------------------
518// Tests
519// ---------------------------------------------------------------------------
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    fn make_daemon_config(run: &str) -> PitchforkTomlDaemon {
526        PitchforkTomlDaemon {
527            run: run.to_string(),
528            ..Default::default()
529        }
530    }
531
532    fn make_context_with_daemon(name: &str, ports: Vec<u16>) -> TemplateContext {
533        let id = DaemonId::new("myproj", name);
534        let config = make_daemon_config("echo");
535        let mut resolved = HashMap::new();
536        resolved.insert(id.clone(), ports);
537        let mut configs = IndexMap::new();
538        configs.insert(id.clone(), make_daemon_config("echo"));
539        TemplateContext::new(
540            &DaemonId::new("myproj", "self"),
541            &config,
542            &resolved,
543            &configs,
544        )
545    }
546
547    #[test]
548    fn test_no_template_passthrough() {
549        let ctx = make_context_with_daemon("redis", vec![6379]);
550        assert_eq!(render_template("hello world", &ctx).unwrap(), "hello world");
551    }
552
553    #[test]
554    fn test_self_variables() {
555        let id = DaemonId::new("myproj", "api");
556        let config = make_daemon_config("echo");
557        let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
558
559        assert_eq!(render_template("{{ name }}", &ctx).unwrap(), "api");
560        assert_eq!(render_template("{{ namespace }}", &ctx).unwrap(), "myproj");
561        assert_eq!(render_template("{{ id }}", &ctx).unwrap(), "myproj/api");
562    }
563
564    #[test]
565    fn test_daemon_port_reference() {
566        let ctx = make_context_with_daemon("redis", vec![6379]);
567        assert_eq!(
568            render_template("{{ daemons.redis.port }}", &ctx).unwrap(),
569            "6379"
570        );
571    }
572
573    #[test]
574    fn test_daemon_ports_array() {
575        let ctx = make_context_with_daemon("redis", vec![6379, 6380]);
576        assert_eq!(
577            render_template("{{ daemons.redis.ports[0] }}", &ctx).unwrap(),
578            "6379"
579        );
580        assert_eq!(
581            render_template("{{ daemons.redis.ports[1] }}", &ctx).unwrap(),
582            "6380"
583        );
584    }
585
586    #[test]
587    fn test_daemon_qualified_name() {
588        let ctx = make_context_with_daemon("redis", vec![6379]);
589        assert_eq!(
590            render_template("{{ daemons[\"myproj.redis\"].port }}", &ctx).unwrap(),
591            "6379"
592        );
593    }
594
595    #[test]
596    fn test_short_name_only_matches_current_namespace() {
597        let self_id = DaemonId::new("app", "api");
598        let self_config = make_daemon_config("echo");
599        let other_id = DaemonId::new("infra", "redis");
600
601        let mut resolved = HashMap::new();
602        resolved.insert(other_id.clone(), vec![6379]);
603
604        let mut configs = IndexMap::new();
605        configs.insert(other_id.clone(), make_daemon_config("echo"));
606
607        let ctx = TemplateContext::new(&self_id, &self_config, &resolved, &configs);
608
609        assert!(render_template("{{ daemons.redis.port }}", &ctx).is_err());
610        assert_eq!(
611            render_template("{{ daemons[\"infra.redis\"].port }}", &ctx).unwrap(),
612            "6379"
613        );
614    }
615
616    #[test]
617    fn test_settings_reference() {
618        let ctx = make_context_with_daemon("redis", vec![6379]);
619        let result = render_template("{{ settings.proxy.tld }}", &ctx).unwrap();
620        // Default TLD is "localhost"
621        assert_eq!(result, "localhost");
622    }
623
624    #[test]
625    fn test_undefined_variable_error() {
626        let ctx = make_context_with_daemon("redis", vec![6379]);
627        let result = render_template("{{ nonexistent }}", &ctx);
628        assert!(result.is_err());
629    }
630
631    #[test]
632    fn test_comment_only_template_is_parsed() {
633        let ctx = make_context_with_daemon("redis", vec![6379]);
634        assert_eq!(
635            render_template("before{# hidden #}after", &ctx).unwrap(),
636            "beforeafter"
637        );
638    }
639
640    #[test]
641    fn test_proxy_url_is_present_as_null_when_slug_is_missing() {
642        let id = DaemonId::new("myproj", "api");
643        let config = make_daemon_config("echo");
644        let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
645
646        assert_eq!(
647            render_template("{{ proxy_url | default(value=\"none\") }}", &ctx).unwrap(),
648            "none"
649        );
650    }
651
652    #[test]
653    fn test_mixed_template_and_literal() {
654        let ctx = make_context_with_daemon("redis", vec![6379]);
655        assert_eq!(
656            render_template("redis://localhost:{{ daemons.redis.port }}/0", &ctx).unwrap(),
657            "redis://localhost:6379/0"
658        );
659    }
660
661    #[test]
662    fn test_render_daemon_templates_run() {
663        let mut ctx = make_context_with_daemon("redis", vec![6379]);
664        let mut config = PitchforkTomlDaemon {
665            run: "redis-cli -p {{ daemons.redis.port }}".to_string(),
666            ..Default::default()
667        };
668        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
669        assert_eq!(config.run, "redis-cli -p 6379");
670    }
671
672    #[test]
673    fn test_render_daemon_templates_env() {
674        let mut ctx = make_context_with_daemon("redis", vec![6379]);
675        let mut config = PitchforkTomlDaemon {
676            run: "echo".to_string(),
677            env: Some(IndexMap::from([
678                (
679                    "DATABASE_URL".to_string(),
680                    "redis://localhost:{{ daemons.redis.port }}/0".to_string(),
681                ),
682                ("STATIC_VAR".to_string(), "unchanged".to_string()),
683            ])),
684            ..Default::default()
685        };
686        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
687        let env = config.env.unwrap();
688        assert_eq!(env["DATABASE_URL"], "redis://localhost:6379/0");
689        assert_eq!(env["STATIC_VAR"], "unchanged");
690    }
691
692    #[test]
693    fn test_top_level_env_merged_as_defaults() {
694        let mut ctx = make_context_with_daemon("redis", vec![6379]);
695        let top_env = IndexMap::from([
696            ("GRAM_HOST".to_string(), "localhost".to_string()),
697            (
698                "GRAM_URL".to_string(),
699                "redis://{{ daemons.redis.port }}".to_string(),
700            ),
701        ]);
702        let mut config = PitchforkTomlDaemon {
703            run: "echo".to_string(),
704            env: Some(IndexMap::from([(
705                "GRAM_HOST".to_string(),
706                "0.0.0.0".to_string(),
707            )])),
708            ..Default::default()
709        };
710        render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).unwrap();
711        let env = config.env.unwrap();
712        // per-daemon wins
713        assert_eq!(env["GRAM_HOST"], "0.0.0.0");
714        // top-level default with template rendered
715        assert_eq!(env["GRAM_URL"], "redis://6379");
716    }
717
718    #[test]
719    fn test_env_exposed_in_context_for_other_fields() {
720        let mut ctx = make_context_with_daemon("redis", vec![6379]);
721        let top_env = IndexMap::from([("GRAM_HOST".to_string(), "localhost".to_string())]);
722        let mut config = PitchforkTomlDaemon {
723            run: "echo {{ env.GRAM_HOST }}".to_string(),
724            ..Default::default()
725        };
726        render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).unwrap();
727        assert_eq!(config.run, "echo localhost");
728    }
729
730    #[test]
731    fn test_env_values_cannot_self_reference() {
732        let mut ctx = make_context_with_daemon("redis", vec![6379]);
733        let top_env = IndexMap::from([
734            ("A".to_string(), "value-a".to_string()),
735            // env is not available while env values are rendered
736            ("B".to_string(), "{{ env.A }}".to_string()),
737        ]);
738        let mut config = PitchforkTomlDaemon {
739            run: "echo".to_string(),
740            ..Default::default()
741        };
742        // Rendering env.B references {{ env.A }} which is undefined during
743        // env-value rendering -> error propagates.
744        assert!(render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).is_err());
745    }
746
747    #[test]
748    fn test_render_daemon_templates_on_output_run() {
749        let mut ctx = make_context_with_daemon("redis", vec![6379]);
750        let mut config = PitchforkTomlDaemon {
751            run: "echo".to_string(),
752            hooks: Some(crate::config_types::PitchforkTomlHooks {
753                on_ready: None,
754                on_fail: None,
755                on_retry: None,
756                on_stop: None,
757                on_exit: None,
758                on_output: Some(crate::config_types::OnOutputHook {
759                    run: "curl http://localhost:{{ daemons.redis.port }}".to_string(),
760                    filter: Some("ready".to_string()),
761                    regex: None,
762                    debounce: None,
763                }),
764            }),
765            ..Default::default()
766        };
767
768        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
769
770        let hooks = config.hooks.unwrap();
771        let on_output = hooks.on_output.unwrap();
772        assert_eq!(on_output.run, "curl http://localhost:6379");
773        assert_eq!(on_output.filter.as_deref(), Some("ready"));
774    }
775
776    #[test]
777    fn test_render_daemon_templates_ready_fields() {
778        use crate::config_types::{ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort};
779
780        let mut ctx = make_context_with_daemon("redis", vec![6379]);
781        let mut config = PitchforkTomlDaemon {
782            run: "echo".to_string(),
783            ready_cmd: Some(ReadyCmd::new("redis-cli -p {{ daemons.redis.port }} ping")),
784            ready_output: Some(ReadyOutput::new("listening on {{ daemons.redis.port }}")),
785            ready_http: Some(ReadyHttp {
786                url: "http://localhost:{{ daemons.redis.port }}/health".to_string(),
787                status: vec![200, 401],
788                timeout: None,
789            }),
790            ready_port: Some(ReadyPort::from_template("{{ daemons.redis.port }}")),
791            ..Default::default()
792        };
793
794        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
795
796        assert_eq!(
797            config.ready_cmd.as_ref().unwrap().run,
798            "redis-cli -p 6379 ping"
799        );
800        assert_eq!(
801            config.ready_output.as_ref().unwrap().pattern,
802            "listening on 6379"
803        );
804        let http = config.ready_http.unwrap();
805        assert_eq!(http.url, "http://localhost:6379/health");
806        assert_eq!(http.status, vec![200, 401]);
807        assert_eq!(config.ready_port, Some(ReadyPort::new(6379)));
808    }
809
810    #[test]
811    fn test_render_daemon_templates_health_fields() {
812        use crate::config_types::{HealthCmd, HealthHttp};
813        use std::time::Duration;
814
815        let mut ctx = make_context_with_daemon("redis", vec![6379]);
816        let mut config = PitchforkTomlDaemon {
817            run: "echo".to_string(),
818            health_cmd: Some(HealthCmd {
819                run: "redis-cli -p {{ daemons.redis.port }} ping".to_string(),
820                interval: Some(Duration::from_secs(10)),
821                timeout: None,
822                retries: Some(3),
823            }),
824            health_http: Some(HealthHttp {
825                url: "http://localhost:{{ daemons.redis.port }}/health".to_string(),
826                status: vec![200],
827                interval: None,
828                timeout: Some(Duration::from_secs(5)),
829                retries: None,
830            }),
831            ..Default::default()
832        };
833
834        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
835
836        let hc = config.health_cmd.unwrap();
837        assert_eq!(hc.run, "redis-cli -p 6379 ping");
838        assert_eq!(hc.interval, Some(Duration::from_secs(10)));
839        assert_eq!(hc.retries, Some(3));
840        let hh = config.health_http.unwrap();
841        assert_eq!(hh.url, "http://localhost:6379/health");
842        assert_eq!(hh.status, vec![200]);
843        assert_eq!(hh.timeout, Some(Duration::from_secs(5)));
844    }
845
846    #[test]
847    fn test_render_daemon_templates_ready_port_invalid() {
848        use crate::config_types::ReadyPort;
849
850        let mut ctx = make_context_with_daemon("redis", vec![6379]);
851        let mut config = PitchforkTomlDaemon {
852            run: "echo".to_string(),
853            ready_port: Some(ReadyPort::from_template("{{ name }}")),
854            ..Default::default()
855        };
856
857        let err = render_daemon_templates(&mut config, &mut ctx, None).unwrap_err();
858        assert!(matches!(err, RenderError::InvalidPort { .. }));
859    }
860
861    #[test]
862    fn test_render_daemon_templates_ready_port_literal_untouched() {
863        use crate::config_types::ReadyPort;
864
865        let mut ctx = make_context_with_daemon("redis", vec![6379]);
866        let mut config = PitchforkTomlDaemon {
867            run: "echo".to_string(),
868            ready_port: Some(ReadyPort::new(8080)),
869            ..Default::default()
870        };
871
872        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
873        assert_eq!(config.ready_port, Some(ReadyPort::new(8080)));
874    }
875
876    #[test]
877    fn test_render_daemon_templates_health_port() {
878        use crate::config_types::HealthPort;
879        use std::time::Duration;
880
881        let mut ctx = make_context_with_daemon("redis", vec![6379]);
882        let mut config = PitchforkTomlDaemon {
883            run: "echo".to_string(),
884            health_port: Some(HealthPort {
885                port: None,
886                template: Some("{{ daemons.redis.port }}".to_string()),
887                interval: Some(Duration::from_secs(10)),
888                retries: Some(3),
889                timeout: Some(Duration::from_secs(5)),
890            }),
891            ..Default::default()
892        };
893
894        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
895        let hp = config.health_port.unwrap();
896        assert_eq!(hp.port, Some(6379));
897        assert!(hp.template.is_none());
898        assert_eq!(hp.interval, Some(Duration::from_secs(10)));
899        assert_eq!(hp.retries, Some(3));
900        assert_eq!(hp.timeout, Some(Duration::from_secs(5)));
901    }
902
903    #[test]
904    fn test_render_daemon_templates_health_port_invalid() {
905        use crate::config_types::HealthPort;
906
907        let mut ctx = make_context_with_daemon("redis", vec![6379]);
908        let mut config = PitchforkTomlDaemon {
909            run: "echo".to_string(),
910            health_port: Some(HealthPort::from_template("{{ name }}")),
911            ..Default::default()
912        };
913
914        let err = render_daemon_templates(&mut config, &mut ctx, None).unwrap_err();
915        assert!(matches!(err, RenderError::InvalidPort { .. }));
916    }
917
918    #[test]
919    fn test_render_daemon_templates_health_port_literal_untouched() {
920        use crate::config_types::HealthPort;
921
922        let mut ctx = make_context_with_daemon("redis", vec![6379]);
923        let mut config = PitchforkTomlDaemon {
924            run: "echo".to_string(),
925            health_port: Some(HealthPort::new(8080)),
926            ..Default::default()
927        };
928
929        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
930        assert_eq!(config.health_port, Some(HealthPort::new(8080)));
931    }
932
933    #[test]
934    fn test_render_daemon_templates_hook_error_does_not_fail() {
935        let mut ctx = make_context_with_daemon("redis", vec![6379]);
936        let mut config = PitchforkTomlDaemon {
937            run: "echo".to_string(),
938            hooks: Some(crate::config_types::PitchforkTomlHooks {
939                on_ready: Some("{{ nonexistent }}".to_string()),
940                on_fail: None,
941                on_retry: None,
942                on_stop: None,
943                on_exit: None,
944                on_output: None,
945            }),
946            ..Default::default()
947        };
948
949        // Hook template errors are silently converted to None — daemon still starts
950        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
951        let hooks = config.hooks.unwrap();
952        assert!(hooks.on_ready.is_none());
953    }
954
955    #[test]
956    fn test_render_daemon_templates_run_error_still_fails() {
957        let mut ctx = make_context_with_daemon("redis", vec![6379]);
958        let mut config = PitchforkTomlDaemon {
959            run: "{{ nonexistent }}".to_string(),
960            ..Default::default()
961        };
962
963        // Non-hook template errors still propagate as Err
964        assert!(render_daemon_templates(&mut config, &mut ctx, None).is_err());
965    }
966}