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