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