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    config.run = renderer.render(&config.run)?;
314
315    if let Some(ref hooks) = config.hooks {
316        let rendered = crate::config_types::PitchforkTomlHooks {
317            on_ready: hooks
318                .on_ready
319                .as_deref()
320                .and_then(|t| renderer.render(t).ok()),
321            on_fail: hooks
322                .on_fail
323                .as_deref()
324                .and_then(|t| renderer.render(t).ok()),
325            on_retry: hooks
326                .on_retry
327                .as_deref()
328                .and_then(|t| renderer.render(t).ok()),
329            on_stop: hooks
330                .on_stop
331                .as_deref()
332                .and_then(|t| renderer.render(t).ok()),
333            on_exit: hooks
334                .on_exit
335                .as_deref()
336                .and_then(|t| renderer.render(t).ok()),
337            on_output: hooks.on_output.as_ref().and_then(|hook| {
338                renderer
339                    .render(&hook.run)
340                    .ok()
341                    .map(|run| crate::config_types::OnOutputHook {
342                        run,
343                        filter: hook.filter.clone(),
344                        regex: hook.regex.clone(),
345                        debounce: hook.debounce.clone(),
346                    })
347            }),
348        };
349        config.hooks = Some(rendered);
350    }
351
352    if let Some(ref cmd) = config.ready_cmd {
353        config.ready_cmd = Some(crate::pitchfork_toml::ReadyCmd {
354            run: renderer.render(&cmd.run)?,
355            timeout: cmd.timeout,
356        });
357    }
358
359    if let Some(ref hc) = config.health_cmd {
360        config.health_cmd = Some(crate::pitchfork_toml::HealthCmd {
361            run: renderer.render(&hc.run)?,
362            interval: hc.interval,
363            timeout: hc.timeout,
364            retries: hc.retries,
365        });
366    }
367
368    if let Some(ref http) = config.health_http {
369        let mut http = http.clone();
370        http.url = renderer.render(&http.url)?;
371        config.health_http = Some(http);
372    }
373
374    if let Some(ref health_port) = config.health_port
375        && let Some(ref template) = health_port.template
376    {
377        let rendered = renderer.render(template)?;
378        let port = rendered
379            .trim()
380            .parse::<u16>()
381            .ok()
382            .filter(|&p| p > 0)
383            .ok_or_else(|| RenderError::InvalidPort {
384                field: "health_port",
385                template: template.clone(),
386                rendered: rendered.clone(),
387            })?;
388        config.health_port = Some(crate::config_types::HealthPort {
389            port: Some(port),
390            template: None,
391            interval: health_port.interval,
392            retries: health_port.retries,
393            timeout: health_port.timeout,
394        });
395    }
396
397    if let Some(ref output) = config.ready_output {
398        let pattern = renderer.render(&output.pattern)?;
399        config.ready_output = Some(crate::config_types::ReadyOutput {
400            pattern,
401            timeout: output.timeout,
402        });
403    }
404
405    if let Some(ref http) = config.ready_http {
406        let mut http = http.clone();
407        http.url = renderer.render(&http.url)?;
408        config.ready_http = Some(http);
409    }
410
411    if let Some(ref ready_port) = config.ready_port
412        && let Some(ref template) = ready_port.template
413    {
414        let rendered = renderer.render(template)?;
415        let port = rendered
416            .trim()
417            .parse::<u16>()
418            .ok()
419            .filter(|&p| p > 0)
420            .ok_or_else(|| RenderError::InvalidPort {
421                field: "ready_port",
422                template: template.clone(),
423                rendered: rendered.clone(),
424            })?;
425        config.ready_port = Some(crate::config_types::ReadyPort {
426            port: Some(port),
427            template: None,
428            timeout: ready_port.timeout,
429        });
430    }
431
432    Ok(())
433}
434
435fn contains_template_syntax(template: &str) -> bool {
436    template.contains("{{") || template.contains("{%") || template.contains("{#")
437}
438
439struct TemplateRenderer {
440    tera: tera::Tera,
441    context: tera::Context,
442    next_template_id: usize,
443}
444
445impl TemplateRenderer {
446    fn new(context: &TemplateContext) -> Self {
447        let mut tera = tera::Tera::default();
448        tera.register_filter(
449            "default",
450            |value: &tera::Value,
451             kwargs: tera::Kwargs,
452             _: &tera::State|
453             -> tera::TeraResult<tera::Value> {
454                let default_val = kwargs.must_get::<tera::Value>("value")?;
455                let boolean = kwargs.get::<bool>("boolean")?.unwrap_or_default();
456                if value.is_undefined() || value.is_none() || (boolean && !value.is_truthy()) {
457                    Ok(default_val)
458                } else {
459                    Ok(value.clone())
460                }
461            },
462        );
463        Self {
464            tera,
465            context: context.to_tera_context(),
466            next_template_id: 0,
467        }
468    }
469
470    fn render(&mut self, template: &str) -> Result<String, RenderError> {
471        if !contains_template_syntax(template) {
472            return Ok(template.to_string());
473        }
474
475        let template_name = format!("config_{}", self.next_template_id);
476        self.next_template_id += 1;
477
478        self.tera
479            .add_raw_template(&template_name, template)
480            .map_err(|e| RenderError::TemplateSyntax {
481                template: template.to_string(),
482                source: e,
483            })?;
484
485        self.tera
486            .render(&template_name, &self.context)
487            .map_err(|e| RenderError::RenderFailed {
488                template: template.to_string(),
489                source: e,
490            })
491    }
492}
493
494// ---------------------------------------------------------------------------
495// RenderError
496// ---------------------------------------------------------------------------
497
498#[derive(Debug, thiserror::Error)]
499pub enum RenderError {
500    #[error("template syntax error in {template:?}: {source}")]
501    TemplateSyntax {
502        template: String,
503        source: tera::Error,
504    },
505    #[error("template render failed for {template:?}: {source}")]
506    RenderFailed {
507        template: String,
508        source: tera::Error,
509    },
510    #[error(
511        "{field} template {template:?} rendered to {rendered:?}, expected a port number (1-65535)"
512    )]
513    InvalidPort {
514        field: &'static str,
515        template: String,
516        rendered: String,
517    },
518}
519
520// ---------------------------------------------------------------------------
521// Tests
522// ---------------------------------------------------------------------------
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    fn make_daemon_config(run: &str) -> PitchforkTomlDaemon {
529        PitchforkTomlDaemon {
530            run: run.to_string(),
531            ..Default::default()
532        }
533    }
534
535    fn make_context_with_daemon(name: &str, ports: Vec<u16>) -> TemplateContext {
536        let id = DaemonId::new("myproj", name);
537        let config = make_daemon_config("echo");
538        let mut resolved = HashMap::new();
539        resolved.insert(id.clone(), ports);
540        let mut configs = IndexMap::new();
541        configs.insert(id.clone(), make_daemon_config("echo"));
542        TemplateContext::new(
543            &DaemonId::new("myproj", "self"),
544            &config,
545            &resolved,
546            &configs,
547        )
548    }
549
550    #[test]
551    fn test_no_template_passthrough() {
552        let ctx = make_context_with_daemon("redis", vec![6379]);
553        assert_eq!(render_template("hello world", &ctx).unwrap(), "hello world");
554    }
555
556    #[test]
557    fn test_self_variables() {
558        let id = DaemonId::new("myproj", "api");
559        let config = make_daemon_config("echo");
560        let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
561
562        assert_eq!(render_template("{{ name }}", &ctx).unwrap(), "api");
563        assert_eq!(render_template("{{ namespace }}", &ctx).unwrap(), "myproj");
564        assert_eq!(render_template("{{ id }}", &ctx).unwrap(), "myproj/api");
565    }
566
567    #[test]
568    fn test_daemon_port_reference() {
569        let ctx = make_context_with_daemon("redis", vec![6379]);
570        assert_eq!(
571            render_template("{{ daemons.redis.port }}", &ctx).unwrap(),
572            "6379"
573        );
574    }
575
576    #[test]
577    fn test_daemon_ports_array() {
578        let ctx = make_context_with_daemon("redis", vec![6379, 6380]);
579        assert_eq!(
580            render_template("{{ daemons.redis.ports[0] }}", &ctx).unwrap(),
581            "6379"
582        );
583        assert_eq!(
584            render_template("{{ daemons.redis.ports[1] }}", &ctx).unwrap(),
585            "6380"
586        );
587    }
588
589    #[test]
590    fn test_daemon_qualified_name() {
591        let ctx = make_context_with_daemon("redis", vec![6379]);
592        assert_eq!(
593            render_template("{{ daemons[\"myproj.redis\"].port }}", &ctx).unwrap(),
594            "6379"
595        );
596    }
597
598    #[test]
599    fn test_short_name_only_matches_current_namespace() {
600        let self_id = DaemonId::new("app", "api");
601        let self_config = make_daemon_config("echo");
602        let other_id = DaemonId::new("infra", "redis");
603
604        let mut resolved = HashMap::new();
605        resolved.insert(other_id.clone(), vec![6379]);
606
607        let mut configs = IndexMap::new();
608        configs.insert(other_id.clone(), make_daemon_config("echo"));
609
610        let ctx = TemplateContext::new(&self_id, &self_config, &resolved, &configs);
611
612        assert!(render_template("{{ daemons.redis.port }}", &ctx).is_err());
613        assert_eq!(
614            render_template("{{ daemons[\"infra.redis\"].port }}", &ctx).unwrap(),
615            "6379"
616        );
617    }
618
619    #[test]
620    fn test_settings_reference() {
621        let ctx = make_context_with_daemon("redis", vec![6379]);
622        let result = render_template("{{ settings.proxy.tld }}", &ctx).unwrap();
623        // Default TLD is "localhost"
624        assert_eq!(result, "localhost");
625    }
626
627    #[test]
628    fn test_undefined_variable_error() {
629        let ctx = make_context_with_daemon("redis", vec![6379]);
630        let result = render_template("{{ nonexistent }}", &ctx);
631        assert!(result.is_err());
632    }
633
634    #[test]
635    fn test_comment_only_template_is_parsed() {
636        let ctx = make_context_with_daemon("redis", vec![6379]);
637        assert_eq!(
638            render_template("before{# hidden #}after", &ctx).unwrap(),
639            "beforeafter"
640        );
641    }
642
643    #[test]
644    fn test_proxy_url_is_present_as_null_when_slug_is_missing() {
645        let id = DaemonId::new("myproj", "api");
646        let config = make_daemon_config("echo");
647        let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
648
649        assert_eq!(
650            render_template("{{ proxy_url | default(value=\"none\") }}", &ctx).unwrap(),
651            "none"
652        );
653    }
654
655    /// The daemon being rendered and a daemon it references resolve their
656    /// hostnames the same way, so `{{ host }}` is never populated while
657    /// `{{ daemons.*.host }}` is null — whether or not the proxy is enabled.
658    #[test]
659    fn test_self_and_referenced_hosts_agree() {
660        let id = DaemonId::try_new("myproj", "api").unwrap();
661        let config = PitchforkTomlDaemon {
662            run: "server".to_string(),
663            port: Some(crate::config_types::PortConfig {
664                expect: vec![3000],
665                ..Default::default()
666            }),
667            ..PitchforkTomlDaemon::default()
668        };
669        let mut configs = IndexMap::new();
670        configs.insert(id.clone(), config.clone());
671        let mut resolved = HashMap::new();
672        resolved.insert(id.clone(), vec![3000]);
673
674        let ctx = TemplateContext::new(&id, &config, &resolved, &configs);
675        assert_eq!(
676            ctx.self_state.host,
677            ctx.daemon_states.get("api").unwrap().host
678        );
679    }
680
681    #[test]
682    fn test_mixed_template_and_literal() {
683        let ctx = make_context_with_daemon("redis", vec![6379]);
684        assert_eq!(
685            render_template("redis://localhost:{{ daemons.redis.port }}/0", &ctx).unwrap(),
686            "redis://localhost:6379/0"
687        );
688    }
689
690    #[test]
691    fn test_render_daemon_templates_run() {
692        let mut ctx = make_context_with_daemon("redis", vec![6379]);
693        let mut config = PitchforkTomlDaemon {
694            run: "redis-cli -p {{ daemons.redis.port }}".to_string(),
695            ..Default::default()
696        };
697        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
698        assert_eq!(config.run, "redis-cli -p 6379");
699    }
700
701    #[test]
702    fn test_render_daemon_templates_env() {
703        let mut ctx = make_context_with_daemon("redis", vec![6379]);
704        let mut config = PitchforkTomlDaemon {
705            run: "echo".to_string(),
706            env: Some(IndexMap::from([
707                (
708                    "DATABASE_URL".to_string(),
709                    "redis://localhost:{{ daemons.redis.port }}/0".to_string(),
710                ),
711                ("STATIC_VAR".to_string(), "unchanged".to_string()),
712            ])),
713            ..Default::default()
714        };
715        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
716        let env = config.env.unwrap();
717        assert_eq!(env["DATABASE_URL"], "redis://localhost:6379/0");
718        assert_eq!(env["STATIC_VAR"], "unchanged");
719    }
720
721    #[test]
722    fn test_top_level_env_merged_as_defaults() {
723        let mut ctx = make_context_with_daemon("redis", vec![6379]);
724        let top_env = IndexMap::from([
725            ("GRAM_HOST".to_string(), "localhost".to_string()),
726            (
727                "GRAM_URL".to_string(),
728                "redis://{{ daemons.redis.port }}".to_string(),
729            ),
730        ]);
731        let mut config = PitchforkTomlDaemon {
732            run: "echo".to_string(),
733            env: Some(IndexMap::from([(
734                "GRAM_HOST".to_string(),
735                "0.0.0.0".to_string(),
736            )])),
737            ..Default::default()
738        };
739        render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).unwrap();
740        let env = config.env.unwrap();
741        // per-daemon wins
742        assert_eq!(env["GRAM_HOST"], "0.0.0.0");
743        // top-level default with template rendered
744        assert_eq!(env["GRAM_URL"], "redis://6379");
745    }
746
747    #[test]
748    fn test_env_exposed_in_context_for_other_fields() {
749        let mut ctx = make_context_with_daemon("redis", vec![6379]);
750        let top_env = IndexMap::from([("GRAM_HOST".to_string(), "localhost".to_string())]);
751        let mut config = PitchforkTomlDaemon {
752            run: "echo {{ env.GRAM_HOST }}".to_string(),
753            ..Default::default()
754        };
755        render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).unwrap();
756        assert_eq!(config.run, "echo localhost");
757    }
758
759    #[test]
760    fn test_env_values_cannot_self_reference() {
761        let mut ctx = make_context_with_daemon("redis", vec![6379]);
762        let top_env = IndexMap::from([
763            ("A".to_string(), "value-a".to_string()),
764            // env is not available while env values are rendered
765            ("B".to_string(), "{{ env.A }}".to_string()),
766        ]);
767        let mut config = PitchforkTomlDaemon {
768            run: "echo".to_string(),
769            ..Default::default()
770        };
771        // Rendering env.B references {{ env.A }} which is undefined during
772        // env-value rendering -> error propagates.
773        assert!(render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).is_err());
774    }
775
776    #[test]
777    fn test_render_daemon_templates_on_output_run() {
778        let mut ctx = make_context_with_daemon("redis", vec![6379]);
779        let mut config = PitchforkTomlDaemon {
780            run: "echo".to_string(),
781            hooks: Some(crate::config_types::PitchforkTomlHooks {
782                on_ready: None,
783                on_fail: None,
784                on_retry: None,
785                on_stop: None,
786                on_exit: None,
787                on_output: Some(crate::config_types::OnOutputHook {
788                    run: "curl http://localhost:{{ daemons.redis.port }}".to_string(),
789                    filter: Some("ready".to_string()),
790                    regex: None,
791                    debounce: None,
792                }),
793            }),
794            ..Default::default()
795        };
796
797        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
798
799        let hooks = config.hooks.unwrap();
800        let on_output = hooks.on_output.unwrap();
801        assert_eq!(on_output.run, "curl http://localhost:6379");
802        assert_eq!(on_output.filter.as_deref(), Some("ready"));
803    }
804
805    #[test]
806    fn test_render_daemon_templates_ready_fields() {
807        use crate::config_types::{ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort};
808
809        let mut ctx = make_context_with_daemon("redis", vec![6379]);
810        let mut config = PitchforkTomlDaemon {
811            run: "echo".to_string(),
812            ready_cmd: Some(ReadyCmd::new("redis-cli -p {{ daemons.redis.port }} ping")),
813            ready_output: Some(ReadyOutput::new("listening on {{ daemons.redis.port }}")),
814            ready_http: Some(ReadyHttp {
815                url: "http://localhost:{{ daemons.redis.port }}/health".to_string(),
816                status: vec![200, 401],
817                timeout: None,
818            }),
819            ready_port: Some(ReadyPort::from_template("{{ daemons.redis.port }}")),
820            ..Default::default()
821        };
822
823        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
824
825        assert_eq!(
826            config.ready_cmd.as_ref().unwrap().run,
827            "redis-cli -p 6379 ping"
828        );
829        assert_eq!(
830            config.ready_output.as_ref().unwrap().pattern,
831            "listening on 6379"
832        );
833        let http = config.ready_http.unwrap();
834        assert_eq!(http.url, "http://localhost:6379/health");
835        assert_eq!(http.status, vec![200, 401]);
836        assert_eq!(config.ready_port, Some(ReadyPort::new(6379)));
837    }
838
839    #[test]
840    fn test_render_daemon_templates_health_fields() {
841        use crate::config_types::{HealthCmd, HealthHttp};
842        use std::time::Duration;
843
844        let mut ctx = make_context_with_daemon("redis", vec![6379]);
845        let mut config = PitchforkTomlDaemon {
846            run: "echo".to_string(),
847            health_cmd: Some(HealthCmd {
848                run: "redis-cli -p {{ daemons.redis.port }} ping".to_string(),
849                interval: Some(Duration::from_secs(10)),
850                timeout: None,
851                retries: Some(3),
852            }),
853            health_http: Some(HealthHttp {
854                url: "http://localhost:{{ daemons.redis.port }}/health".to_string(),
855                status: vec![200],
856                interval: None,
857                timeout: Some(Duration::from_secs(5)),
858                retries: None,
859            }),
860            ..Default::default()
861        };
862
863        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
864
865        let hc = config.health_cmd.unwrap();
866        assert_eq!(hc.run, "redis-cli -p 6379 ping");
867        assert_eq!(hc.interval, Some(Duration::from_secs(10)));
868        assert_eq!(hc.retries, Some(3));
869        let hh = config.health_http.unwrap();
870        assert_eq!(hh.url, "http://localhost:6379/health");
871        assert_eq!(hh.status, vec![200]);
872        assert_eq!(hh.timeout, Some(Duration::from_secs(5)));
873    }
874
875    #[test]
876    fn test_render_daemon_templates_ready_port_invalid() {
877        use crate::config_types::ReadyPort;
878
879        let mut ctx = make_context_with_daemon("redis", vec![6379]);
880        let mut config = PitchforkTomlDaemon {
881            run: "echo".to_string(),
882            ready_port: Some(ReadyPort::from_template("{{ name }}")),
883            ..Default::default()
884        };
885
886        let err = render_daemon_templates(&mut config, &mut ctx, None).unwrap_err();
887        assert!(matches!(err, RenderError::InvalidPort { .. }));
888    }
889
890    #[test]
891    fn test_render_daemon_templates_ready_port_literal_untouched() {
892        use crate::config_types::ReadyPort;
893
894        let mut ctx = make_context_with_daemon("redis", vec![6379]);
895        let mut config = PitchforkTomlDaemon {
896            run: "echo".to_string(),
897            ready_port: Some(ReadyPort::new(8080)),
898            ..Default::default()
899        };
900
901        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
902        assert_eq!(config.ready_port, Some(ReadyPort::new(8080)));
903    }
904
905    #[test]
906    fn test_render_daemon_templates_health_port() {
907        use crate::config_types::HealthPort;
908        use std::time::Duration;
909
910        let mut ctx = make_context_with_daemon("redis", vec![6379]);
911        let mut config = PitchforkTomlDaemon {
912            run: "echo".to_string(),
913            health_port: Some(HealthPort {
914                port: None,
915                template: Some("{{ daemons.redis.port }}".to_string()),
916                interval: Some(Duration::from_secs(10)),
917                retries: Some(3),
918                timeout: Some(Duration::from_secs(5)),
919            }),
920            ..Default::default()
921        };
922
923        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
924        let hp = config.health_port.unwrap();
925        assert_eq!(hp.port, Some(6379));
926        assert!(hp.template.is_none());
927        assert_eq!(hp.interval, Some(Duration::from_secs(10)));
928        assert_eq!(hp.retries, Some(3));
929        assert_eq!(hp.timeout, Some(Duration::from_secs(5)));
930    }
931
932    #[test]
933    fn test_render_daemon_templates_health_port_invalid() {
934        use crate::config_types::HealthPort;
935
936        let mut ctx = make_context_with_daemon("redis", vec![6379]);
937        let mut config = PitchforkTomlDaemon {
938            run: "echo".to_string(),
939            health_port: Some(HealthPort::from_template("{{ name }}")),
940            ..Default::default()
941        };
942
943        let err = render_daemon_templates(&mut config, &mut ctx, None).unwrap_err();
944        assert!(matches!(err, RenderError::InvalidPort { .. }));
945    }
946
947    #[test]
948    fn test_render_daemon_templates_health_port_literal_untouched() {
949        use crate::config_types::HealthPort;
950
951        let mut ctx = make_context_with_daemon("redis", vec![6379]);
952        let mut config = PitchforkTomlDaemon {
953            run: "echo".to_string(),
954            health_port: Some(HealthPort::new(8080)),
955            ..Default::default()
956        };
957
958        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
959        assert_eq!(config.health_port, Some(HealthPort::new(8080)));
960    }
961
962    #[test]
963    fn test_render_daemon_templates_hook_error_does_not_fail() {
964        let mut ctx = make_context_with_daemon("redis", vec![6379]);
965        let mut config = PitchforkTomlDaemon {
966            run: "echo".to_string(),
967            hooks: Some(crate::config_types::PitchforkTomlHooks {
968                on_ready: Some("{{ nonexistent }}".to_string()),
969                on_fail: None,
970                on_retry: None,
971                on_stop: None,
972                on_exit: None,
973                on_output: None,
974            }),
975            ..Default::default()
976        };
977
978        // Hook template errors are silently converted to None — daemon still starts
979        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
980        let hooks = config.hooks.unwrap();
981        assert!(hooks.on_ready.is_none());
982    }
983
984    #[test]
985    fn test_render_daemon_templates_run_error_still_fails() {
986        let mut ctx = make_context_with_daemon("redis", vec![6379]);
987        let mut config = PitchforkTomlDaemon {
988            run: "{{ nonexistent }}".to_string(),
989            ..Default::default()
990        };
991
992        // Non-hook template errors still propagate as Err
993        assert!(render_daemon_templates(&mut config, &mut ctx, None).is_err());
994    }
995}