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