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        for (key, value) in self.to_json_map() {
151            ctx.insert(key, &value);
152        }
153        ctx
154    }
155
156    /// The variables templates can reference, as JSON.
157    fn to_json_map(&self) -> serde_json::Map<String, serde_json::Value> {
158        let mut map = serde_json::Map::new();
159        let mut insert = |key: &str, value: serde_json::Value| {
160            map.insert(key.to_string(), value);
161        };
162
163        // Self variables
164        insert("name", self.self_state.name.clone().into());
165        insert("namespace", self.self_state.namespace.clone().into());
166        insert("id", self.self_state.id.clone().into());
167        insert("slug", serde_json::json!(self.self_state.slug));
168        insert("host", serde_json::json!(self.self_state.host));
169        insert(
170            "dir",
171            self.self_state.dir.to_string_lossy().to_string().into(),
172        );
173
174        // Daemons
175        let mut daemons_map = serde_json::Map::new();
176        for (name, state) in &self.daemon_states {
177            if daemons_map.contains_key(name) {
178                continue;
179            }
180            daemons_map.insert(name.clone(), daemon_state_to_json(state));
181        }
182        insert("daemons", serde_json::Value::Object(daemons_map));
183
184        // Settings
185        let s = settings();
186        insert(
187            "settings",
188            serde_json::json!({
189                "proxy": {
190                    "enable": s.proxy.enable,
191                    "tld": s.proxy.tld,
192                    "port": s.proxy.port,
193                    "https": s.proxy.https,
194                }
195            }),
196        );
197
198        // Always expose proxy_url so templates can distinguish an unroutable daemon
199        // via a strict null value instead of an undefined-variable error.
200        let proxy_url = crate::proxy::build_proxy_url(self.self_state.host.as_deref(), &s);
201        insert("proxy_url", serde_json::json!(proxy_url));
202        // `url` is the current spelling; `proxy_url` stays for existing configs.
203        insert("url", serde_json::json!(proxy_url));
204
205        // Rendered env for this daemon (set via set_env after env rendering)
206        if let Some(ref env) = self.env {
207            let env: serde_json::Map<String, serde_json::Value> = env
208                .iter()
209                .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
210                .collect();
211            insert("env", serde_json::Value::Object(env));
212        }
213
214        map
215    }
216}
217
218fn daemon_state_to_json(state: &DaemonTemplateState) -> serde_json::Value {
219    serde_json::json!({
220        "port": state.port(),
221        "ports": state.ports,
222        "id": state.id,
223        "name": state.name,
224        "namespace": state.namespace,
225        "slug": state.slug,
226        "host": state.host,
227        "url": crate::proxy::build_proxy_url(state.host.as_deref(), &settings()),
228        "dir": state.dir.to_string_lossy(),
229    })
230}
231
232/// Convert a DaemonId into a template key using `namespace.name` format.
233/// E.g. `myproj/redis` -> `myproj.redis`
234fn qualified_key(id: &DaemonId) -> String {
235    format!("{}.{}", id.namespace(), id.name())
236}
237
238// ---------------------------------------------------------------------------
239// Env merge + render helpers
240// ---------------------------------------------------------------------------
241
242/// Merge top-level env with per-daemon env. Per-daemon values win on conflicts.
243pub(crate) fn merge_env(
244    top: Option<&IndexMap<String, String>>,
245    daemon: Option<&IndexMap<String, String>>,
246) -> Option<IndexMap<String, String>> {
247    match (top, daemon) {
248        (None, None) => None,
249        (Some(t), None) => Some(t.clone()),
250        (None, Some(d)) => Some(d.clone()),
251        (Some(t), Some(d)) => {
252            let mut merged = t.clone();
253            for (k, v) in d {
254                merged.insert(k.clone(), v.clone());
255            }
256            Some(merged)
257        }
258    }
259}
260
261/// Merge top-level and per-daemon env, render template values, and return the
262/// rendered env. Used by hook rendering where the daemon config is not mutated.
263///
264/// Env values are rendered with a context that does **not** include `env`
265/// itself, preventing self-reference cycles. Callers that want `{{ env.X }}`
266/// available in subsequent rendering should set the result on the context via
267/// [`TemplateContext::set_env`].
268pub fn render_env(
269    top_env: Option<&IndexMap<String, String>>,
270    daemon_env: Option<&IndexMap<String, String>>,
271    context: &TemplateContext,
272) -> Result<Option<IndexMap<String, String>>, RenderError> {
273    let Some(merged) = merge_env(top_env, daemon_env) else {
274        return Ok(None);
275    };
276    let mut renderer = TemplateRenderer::new(context);
277    let rendered: IndexMap<String, String> = merged
278        .iter()
279        .map(|(k, v)| Ok((k.clone(), renderer.render(v)?)))
280        .collect::<Result<_, _>>()?;
281    Ok(Some(rendered))
282}
283
284// ---------------------------------------------------------------------------
285// Rendering
286// ---------------------------------------------------------------------------
287
288/// Render a Tera template string with the given context.
289///
290/// Returns the rendered string, or an error describing what went wrong.
291/// Fast path: strings without `{{` or `{%` are returned as-is.
292pub fn render_template(template: &str, context: &TemplateContext) -> Result<String, RenderError> {
293    TemplateRenderer::new(context).render(template)
294}
295
296/// Render all template-enabled fields of a daemon config.
297///
298/// Top-level `env` (from `[env]` in pitchfork.toml) is merged into the daemon's
299/// own `env` as defaults — per-daemon values win on key conflicts. Env values
300/// are rendered first (with a context that excludes `env` itself, preventing
301/// self-reference cycles), then the rendered env is exposed as `{{ env.X }}`
302/// for the remaining fields (`run`, `hooks`, `ready_*`).
303///
304/// Modifies the config in place. Returns the first error encountered from
305/// non-hook fields (`run`, `env`, `ready_*`). Hook template errors are
306/// logged as warnings and the hook is set to `None` — hooks are re-rendered
307/// at fire time via `fire_hook`, so pre-rendered hook strings are unused.
308pub fn render_daemon_templates(
309    config: &mut PitchforkTomlDaemon,
310    context: &mut TemplateContext,
311    top_env: Option<&IndexMap<String, String>>,
312) -> Result<(), RenderError> {
313    render_daemon_templates_with(config, context, top_env, runs_under_mise)
314}
315
316/// [`render_daemon_templates`] with the decision whether `mise x` will wrap the
317/// daemon given, so a test does not depend on a mise installation.
318fn render_daemon_templates_with(
319    config: &mut PitchforkTomlDaemon,
320    context: &mut TemplateContext,
321    top_env: Option<&IndexMap<String, String>>,
322    runs_under_mise: impl Fn(&PitchforkTomlDaemon) -> bool,
323) -> Result<(), RenderError> {
324    // Phase 1: merge top-level env into the daemon's env (per-daemon wins) and
325    // render env values. `env` is NOT yet in the context, so env values cannot
326    // reference {{ env.* }} (preventing cycles). render_env builds its own
327    // short-lived renderer without env in scope.
328    let rendered_env = render_env(top_env, config.env.as_ref(), context)?;
329    config.env = rendered_env;
330    // Phase 2: expose the rendered env on the context as the authoritative
331    // state, so to_tera_context() (used by TemplateRenderer::new) includes it.
332    if let Some(ref env) = config.env {
333        context.set_env(env.clone());
334    }
335
336    let mut renderer = TemplateRenderer::new(context);
337
338    // In the argv form each argument is rendered on its own, so a rendered
339    // value with spaces stays one argument.
340    //
341    // A command that `mise x` wraps can reference variables only mise knows, such
342    // as `{{ vars.name }}` from mise.toml. When this context cannot render such a
343    // command it is passed through unrendered, and `mise x` renders it with this
344    // context plus its own, or reports the error itself.
345    let mut defers_to_mise = None;
346    let mut deferred = false;
347    config.run = config.run.try_map(|arg| match renderer.render(arg) {
348        Ok(rendered) => Ok(rendered),
349        Err(err) => {
350            if *defers_to_mise.get_or_insert_with(|| runs_under_mise(config)) {
351                deferred = true;
352                Ok(arg.to_string())
353            } else {
354                Err(err)
355            }
356        }
357    })?;
358    config.deferred_template_context =
359        deferred.then(|| serde_json::Value::Object(context.to_json_map()).to_string());
360
361    if let Some(ref hooks) = config.hooks {
362        let rendered = crate::config_types::PitchforkTomlHooks {
363            on_ready: hooks
364                .on_ready
365                .as_deref()
366                .and_then(|t| renderer.render(t).ok()),
367            on_fail: hooks
368                .on_fail
369                .as_deref()
370                .and_then(|t| renderer.render(t).ok()),
371            on_retry: hooks
372                .on_retry
373                .as_deref()
374                .and_then(|t| renderer.render(t).ok()),
375            on_stop: hooks
376                .on_stop
377                .as_deref()
378                .and_then(|t| renderer.render(t).ok()),
379            on_exit: hooks
380                .on_exit
381                .as_deref()
382                .and_then(|t| renderer.render(t).ok()),
383            on_output: hooks.on_output.as_ref().and_then(|hook| {
384                renderer
385                    .render(&hook.run)
386                    .ok()
387                    .map(|run| crate::config_types::OnOutputHook {
388                        run,
389                        filter: hook.filter.clone(),
390                        regex: hook.regex.clone(),
391                        debounce: hook.debounce.clone(),
392                    })
393            }),
394        };
395        config.hooks = Some(rendered);
396    }
397
398    if let Some(ref cmd) = config.ready_cmd {
399        config.ready_cmd = Some(crate::pitchfork_toml::ReadyCmd {
400            // Each argument of the argv form is rendered on its own and stays
401            // one argument, as for `run`.
402            run: cmd.run.try_map(|arg| renderer.render(arg))?,
403            timeout: cmd.timeout,
404        });
405    }
406
407    if let Some(ref hc) = config.health_cmd {
408        config.health_cmd = Some(crate::pitchfork_toml::HealthCmd {
409            // As for `ready_cmd`, each argument of the argv form is rendered
410            // on its own and stays one argument.
411            run: hc.run.try_map(|arg| renderer.render(arg))?,
412            interval: hc.interval,
413            timeout: hc.timeout,
414            retries: hc.retries,
415        });
416    }
417
418    if let Some(ref http) = config.health_http {
419        let mut http = http.clone();
420        http.url = renderer.render(&http.url)?;
421        config.health_http = Some(http);
422    }
423
424    if let Some(ref health_port) = config.health_port
425        && let Some(ref template) = health_port.template
426    {
427        let rendered = renderer.render(template)?;
428        let port = rendered
429            .trim()
430            .parse::<u16>()
431            .ok()
432            .filter(|&p| p > 0)
433            .ok_or_else(|| RenderError::InvalidPort {
434                field: "health_port",
435                template: template.clone(),
436                rendered: rendered.clone(),
437            })?;
438        config.health_port = Some(crate::config_types::HealthPort {
439            port: Some(port),
440            template: None,
441            interval: health_port.interval,
442            retries: health_port.retries,
443            timeout: health_port.timeout,
444        });
445    }
446
447    if let Some(ref output) = config.ready_output {
448        let pattern = renderer.render(&output.pattern)?;
449        config.ready_output = Some(crate::config_types::ReadyOutput {
450            pattern,
451            timeout: output.timeout,
452        });
453    }
454
455    if let Some(ref http) = config.ready_http {
456        let mut http = http.clone();
457        http.url = renderer.render(&http.url)?;
458        config.ready_http = Some(http);
459    }
460
461    if let Some(ref ready_port) = config.ready_port
462        && let Some(ref template) = ready_port.template
463    {
464        let rendered = renderer.render(template)?;
465        let port = rendered
466            .trim()
467            .parse::<u16>()
468            .ok()
469            .filter(|&p| p > 0)
470            .ok_or_else(|| RenderError::InvalidPort {
471                field: "ready_port",
472                template: template.clone(),
473                rendered: rendered.clone(),
474            })?;
475        config.ready_port = Some(crate::config_types::ReadyPort {
476            port: Some(port),
477            template: None,
478            timeout: ready_port.timeout,
479        });
480    }
481
482    Ok(())
483}
484
485/// Environment variable carrying the template context to the `mise x` wrapper of a
486/// daemon whose command it has to finish rendering.
487pub const TEMPLATE_CONTEXT_ENV: &str = "PITCHFORK_TEMPLATE_CONTEXT";
488
489/// Whether `mise x` is meant to wrap this daemon's command, which is what can finish
490/// rendering a command this context could not. Whether the binary can be found is
491/// decided at launch, by the supervisor that looks for it: a client rendering the
492/// command may not see the same installation.
493fn runs_under_mise(config: &PitchforkTomlDaemon) -> bool {
494    mise_enabled(config)
495}
496
497/// Whether the daemon asks for `mise x`: its own `mise`, else `general.mise` as set
498/// for the daemon's project. A start that does not go through a client's request
499/// must use this same answer, or it would launch without the wrapper that a
500/// deferred command needs.
501pub(crate) fn mise_enabled(config: &PitchforkTomlDaemon) -> bool {
502    config.mise.unwrap_or_else(|| {
503        let dir = crate::ipc::batch::resolve_config_base_dir(config.path.as_deref());
504        crate::settings::Settings::load_from_dir(&dir).general.mise
505    })
506}
507
508fn contains_template_syntax(template: &str) -> bool {
509    template.contains("{{") || template.contains("{%") || template.contains("{#")
510}
511
512struct TemplateRenderer {
513    tera: tera::Tera,
514    context: tera::Context,
515    next_template_id: usize,
516}
517
518impl TemplateRenderer {
519    fn new(context: &TemplateContext) -> Self {
520        let mut tera = tera::Tera::default();
521        tera.register_filter(
522            "default",
523            |value: &tera::Value,
524             kwargs: tera::Kwargs,
525             _: &tera::State|
526             -> tera::TeraResult<tera::Value> {
527                let default_val = kwargs.must_get::<tera::Value>("value")?;
528                let boolean = kwargs.get::<bool>("boolean")?.unwrap_or_default();
529                if value.is_undefined() || value.is_none() || (boolean && !value.is_truthy()) {
530                    Ok(default_val)
531                } else {
532                    Ok(value.clone())
533                }
534            },
535        );
536        Self {
537            tera,
538            context: context.to_tera_context(),
539            next_template_id: 0,
540        }
541    }
542
543    fn add_template(&mut self, template: &str) -> Result<String, RenderError> {
544        let template_name = format!("config_{}", self.next_template_id);
545        self.next_template_id += 1;
546        self.tera
547            .add_raw_template(&template_name, template)
548            .map_err(|e| RenderError::TemplateSyntax {
549                template: template.to_string(),
550                source: e,
551            })?;
552        Ok(template_name)
553    }
554
555    fn render(&mut self, template: &str) -> Result<String, RenderError> {
556        if !contains_template_syntax(template) {
557            return Ok(template.to_string());
558        }
559
560        let template_name = self.add_template(template)?;
561
562        self.tera
563            .render(&template_name, &self.context)
564            .map_err(|e| RenderError::RenderFailed {
565                template: template.to_string(),
566                source: e,
567            })
568    }
569}
570
571// ---------------------------------------------------------------------------
572// RenderError
573// ---------------------------------------------------------------------------
574
575#[derive(Debug, thiserror::Error)]
576pub enum RenderError {
577    #[error("template syntax error in {template:?}: {source}")]
578    TemplateSyntax {
579        template: String,
580        source: tera::Error,
581    },
582    #[error("template render failed for {template:?}: {source}")]
583    RenderFailed {
584        template: String,
585        source: tera::Error,
586    },
587    #[error(
588        "{field} template {template:?} rendered to {rendered:?}, expected a port number (1-65535)"
589    )]
590    InvalidPort {
591        field: &'static str,
592        template: String,
593        rendered: String,
594    },
595}
596
597// ---------------------------------------------------------------------------
598// Tests
599// ---------------------------------------------------------------------------
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    fn make_daemon_config(run: &str) -> PitchforkTomlDaemon {
606        PitchforkTomlDaemon {
607            run: run.into(),
608            ..Default::default()
609        }
610    }
611
612    fn make_context_with_daemon(name: &str, ports: Vec<u16>) -> TemplateContext {
613        let id = DaemonId::new("myproj", name);
614        let config = make_daemon_config("echo");
615        let mut resolved = HashMap::new();
616        resolved.insert(id.clone(), ports);
617        let mut configs = IndexMap::new();
618        configs.insert(id.clone(), make_daemon_config("echo"));
619        TemplateContext::new(
620            &DaemonId::new("myproj", "self"),
621            &config,
622            &resolved,
623            &configs,
624        )
625    }
626
627    #[test]
628    fn test_no_template_passthrough() {
629        let ctx = make_context_with_daemon("redis", vec![6379]);
630        assert_eq!(render_template("hello world", &ctx).unwrap(), "hello world");
631    }
632
633    #[test]
634    fn test_self_variables() {
635        let id = DaemonId::new("myproj", "api");
636        let config = make_daemon_config("echo");
637        let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
638
639        assert_eq!(render_template("{{ name }}", &ctx).unwrap(), "api");
640        assert_eq!(render_template("{{ namespace }}", &ctx).unwrap(), "myproj");
641        assert_eq!(render_template("{{ id }}", &ctx).unwrap(), "myproj/api");
642    }
643
644    #[test]
645    fn test_daemon_port_reference() {
646        let ctx = make_context_with_daemon("redis", vec![6379]);
647        assert_eq!(
648            render_template("{{ daemons.redis.port }}", &ctx).unwrap(),
649            "6379"
650        );
651    }
652
653    #[test]
654    fn test_daemon_ports_array() {
655        let ctx = make_context_with_daemon("redis", vec![6379, 6380]);
656        assert_eq!(
657            render_template("{{ daemons.redis.ports[0] }}", &ctx).unwrap(),
658            "6379"
659        );
660        assert_eq!(
661            render_template("{{ daemons.redis.ports[1] }}", &ctx).unwrap(),
662            "6380"
663        );
664    }
665
666    #[test]
667    fn test_daemon_qualified_name() {
668        let ctx = make_context_with_daemon("redis", vec![6379]);
669        assert_eq!(
670            render_template("{{ daemons[\"myproj.redis\"].port }}", &ctx).unwrap(),
671            "6379"
672        );
673    }
674
675    #[test]
676    fn test_short_name_only_matches_current_namespace() {
677        let self_id = DaemonId::new("app", "api");
678        let self_config = make_daemon_config("echo");
679        let other_id = DaemonId::new("infra", "redis");
680
681        let mut resolved = HashMap::new();
682        resolved.insert(other_id.clone(), vec![6379]);
683
684        let mut configs = IndexMap::new();
685        configs.insert(other_id.clone(), make_daemon_config("echo"));
686
687        let ctx = TemplateContext::new(&self_id, &self_config, &resolved, &configs);
688
689        assert!(render_template("{{ daemons.redis.port }}", &ctx).is_err());
690        assert_eq!(
691            render_template("{{ daemons[\"infra.redis\"].port }}", &ctx).unwrap(),
692            "6379"
693        );
694    }
695
696    #[test]
697    fn test_settings_reference() {
698        let ctx = make_context_with_daemon("redis", vec![6379]);
699        let result = render_template("{{ settings.proxy.tld }}", &ctx).unwrap();
700        // Default TLD is "localhost"
701        assert_eq!(result, "localhost");
702    }
703
704    #[test]
705    fn test_undefined_variable_error() {
706        let ctx = make_context_with_daemon("redis", vec![6379]);
707        let result = render_template("{{ nonexistent }}", &ctx);
708        assert!(result.is_err());
709    }
710
711    #[test]
712    fn test_comment_only_template_is_parsed() {
713        let ctx = make_context_with_daemon("redis", vec![6379]);
714        assert_eq!(
715            render_template("before{# hidden #}after", &ctx).unwrap(),
716            "beforeafter"
717        );
718    }
719
720    #[test]
721    fn test_proxy_url_is_present_as_null_when_slug_is_missing() {
722        let id = DaemonId::new("myproj", "api");
723        let config = make_daemon_config("echo");
724        let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
725
726        assert_eq!(
727            render_template("{{ proxy_url | default(value=\"none\") }}", &ctx).unwrap(),
728            "none"
729        );
730    }
731
732    /// The daemon being rendered and a daemon it references resolve their
733    /// hostnames the same way, so `{{ host }}` is never populated while
734    /// `{{ daemons.*.host }}` is null — whether or not the proxy is enabled.
735    #[test]
736    fn test_self_and_referenced_hosts_agree() {
737        let id = DaemonId::try_new("myproj", "api").unwrap();
738        let config = PitchforkTomlDaemon {
739            run: "server".into(),
740            port: Some(crate::config_types::PortConfig {
741                expect: vec![3000],
742                ..Default::default()
743            }),
744            ..PitchforkTomlDaemon::default()
745        };
746        let mut configs = IndexMap::new();
747        configs.insert(id.clone(), config.clone());
748        let mut resolved = HashMap::new();
749        resolved.insert(id.clone(), vec![3000]);
750
751        let ctx = TemplateContext::new(&id, &config, &resolved, &configs);
752        assert_eq!(
753            ctx.self_state.host,
754            ctx.daemon_states.get("api").unwrap().host
755        );
756    }
757
758    #[test]
759    fn test_mixed_template_and_literal() {
760        let ctx = make_context_with_daemon("redis", vec![6379]);
761        assert_eq!(
762            render_template("redis://localhost:{{ daemons.redis.port }}/0", &ctx).unwrap(),
763            "redis://localhost:6379/0"
764        );
765    }
766
767    #[test]
768    fn test_render_daemon_templates_run() {
769        let mut ctx = make_context_with_daemon("redis", vec![6379]);
770        let mut config = PitchforkTomlDaemon {
771            run: "redis-cli -p {{ daemons.redis.port }}".into(),
772            ..Default::default()
773        };
774        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
775        assert_eq!(config.run, "redis-cli -p 6379");
776    }
777
778    #[test]
779    fn test_run_with_unknown_variable_is_deferred_to_mise() {
780        let mut ctx = make_context_with_daemon("redis", vec![6379]);
781        let mut config = PitchforkTomlDaemon {
782            run: "redis-cli -p {{ daemons.redis.port }} {{ vars.greeting | quote }}".into(),
783            ..Default::default()
784        };
785        render_daemon_templates_with(&mut config, &mut ctx, None, |_| true).unwrap();
786        // The whole command is left for `mise x`, which receives the context.
787        assert_eq!(
788            config.run,
789            "redis-cli -p {{ daemons.redis.port }} {{ vars.greeting | quote }}"
790        );
791        let json: serde_json::Value =
792            serde_json::from_str(config.deferred_template_context.as_ref().unwrap()).unwrap();
793        assert_eq!(json["daemons"]["redis"]["port"], 6379);
794        assert_eq!(json["name"], "self");
795    }
796
797    #[test]
798    fn test_unknown_variable_is_an_error_without_mise() {
799        let mut ctx = make_context_with_daemon("redis", vec![6379]);
800        let mut config = PitchforkTomlDaemon {
801            run: "echo {{ vars.greeting }}".into(),
802            ..Default::default()
803        };
804        // mise is off, so nothing could finish it.
805        assert!(render_daemon_templates_with(&mut config, &mut ctx, None, |_| false).is_err());
806        assert!(config.deferred_template_context.is_none());
807    }
808
809    #[test]
810    fn test_user_declared_context_variable_is_left_alone() {
811        let mut ctx = make_context_with_daemon("redis", vec![6379]);
812        let mut config = PitchforkTomlDaemon {
813            run: "redis-cli -p {{ daemons.redis.port }}".into(),
814            env: Some(IndexMap::from([(
815                TEMPLATE_CONTEXT_ENV.to_string(),
816                "mine".to_string(),
817            )])),
818            ..Default::default()
819        };
820        render_daemon_templates_with(&mut config, &mut ctx, None, |_| true).unwrap();
821        // Not a deferral, and the user's own value is untouched.
822        assert!(config.deferred_template_context.is_none());
823        assert_eq!(config.env.as_ref().unwrap()[TEMPLATE_CONTEXT_ENV], "mine");
824    }
825
826    #[test]
827    fn test_known_variables_are_not_deferred() {
828        let mut ctx = make_context_with_daemon("redis", vec![6379]);
829        let mut config = PitchforkTomlDaemon {
830            run: "redis-cli -p {{ daemons.redis.port }}".into(),
831            ..Default::default()
832        };
833        render_daemon_templates_with(&mut config, &mut ctx, None, |_| true).unwrap();
834        assert_eq!(config.run, "redis-cli -p 6379");
835        assert!(config.deferred_template_context.is_none());
836    }
837
838    #[test]
839    fn test_missing_nested_variable_is_deferred_when_env_is_defined() {
840        let mut ctx = make_context_with_daemon("redis", vec![6379]);
841        let mut config = PitchforkTomlDaemon {
842            run: "echo {{ env.MISE_ONLY }}".into(),
843            env: Some(IndexMap::from([("OWN".to_string(), "x".to_string())])),
844            ..Default::default()
845        };
846        render_daemon_templates_with(&mut config, &mut ctx, None, |_| true).unwrap();
847        assert_eq!(config.run, "echo {{ env.MISE_ONLY }}");
848        let json: serde_json::Value =
849            serde_json::from_str(config.deferred_template_context.as_ref().unwrap()).unwrap();
850        assert_eq!(json["env"]["OWN"], "x");
851    }
852
853    #[test]
854    fn test_render_daemon_templates_run_array() {
855        let mut ctx = make_context_with_daemon("redis", vec![6379]);
856        let mut config = PitchforkTomlDaemon {
857            run: crate::pitchfork_toml::RunCommand::Argv(vec![
858                "redis-cli".into(),
859                "-p".into(),
860                "{{ daemons.redis.port }}".into(),
861                "a b".into(),
862            ]),
863            ..Default::default()
864        };
865        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
866        // Each argument is rendered on its own and stays one argument.
867        assert_eq!(
868            config.run,
869            crate::pitchfork_toml::RunCommand::Argv(vec![
870                "redis-cli".into(),
871                "-p".into(),
872                "6379".into(),
873                "a b".into(),
874            ])
875        );
876    }
877
878    #[test]
879    fn test_render_daemon_templates_env() {
880        let mut ctx = make_context_with_daemon("redis", vec![6379]);
881        let mut config = PitchforkTomlDaemon {
882            run: "echo".into(),
883            env: Some(IndexMap::from([
884                (
885                    "DATABASE_URL".to_string(),
886                    "redis://localhost:{{ daemons.redis.port }}/0".to_string(),
887                ),
888                ("STATIC_VAR".to_string(), "unchanged".to_string()),
889            ])),
890            ..Default::default()
891        };
892        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
893        let env = config.env.unwrap();
894        assert_eq!(env["DATABASE_URL"], "redis://localhost:6379/0");
895        assert_eq!(env["STATIC_VAR"], "unchanged");
896    }
897
898    #[test]
899    fn test_top_level_env_merged_as_defaults() {
900        let mut ctx = make_context_with_daemon("redis", vec![6379]);
901        let top_env = IndexMap::from([
902            ("GRAM_HOST".to_string(), "localhost".to_string()),
903            (
904                "GRAM_URL".to_string(),
905                "redis://{{ daemons.redis.port }}".to_string(),
906            ),
907        ]);
908        let mut config = PitchforkTomlDaemon {
909            run: "echo".into(),
910            env: Some(IndexMap::from([(
911                "GRAM_HOST".to_string(),
912                "0.0.0.0".to_string(),
913            )])),
914            ..Default::default()
915        };
916        render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).unwrap();
917        let env = config.env.unwrap();
918        // per-daemon wins
919        assert_eq!(env["GRAM_HOST"], "0.0.0.0");
920        // top-level default with template rendered
921        assert_eq!(env["GRAM_URL"], "redis://6379");
922    }
923
924    #[test]
925    fn test_env_exposed_in_context_for_other_fields() {
926        let mut ctx = make_context_with_daemon("redis", vec![6379]);
927        let top_env = IndexMap::from([("GRAM_HOST".to_string(), "localhost".to_string())]);
928        let mut config = PitchforkTomlDaemon {
929            run: "echo {{ env.GRAM_HOST }}".into(),
930            ..Default::default()
931        };
932        render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).unwrap();
933        assert_eq!(config.run, "echo localhost");
934    }
935
936    #[test]
937    fn test_env_values_cannot_self_reference() {
938        let mut ctx = make_context_with_daemon("redis", vec![6379]);
939        let top_env = IndexMap::from([
940            ("A".to_string(), "value-a".to_string()),
941            // env is not available while env values are rendered
942            ("B".to_string(), "{{ env.A }}".to_string()),
943        ]);
944        let mut config = PitchforkTomlDaemon {
945            run: "echo".into(),
946            ..Default::default()
947        };
948        // Rendering env.B references {{ env.A }} which is undefined during
949        // env-value rendering -> error propagates.
950        assert!(render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).is_err());
951    }
952
953    #[test]
954    fn test_render_daemon_templates_on_output_run() {
955        let mut ctx = make_context_with_daemon("redis", vec![6379]);
956        let mut config = PitchforkTomlDaemon {
957            run: "echo".into(),
958            hooks: Some(crate::config_types::PitchforkTomlHooks {
959                on_ready: None,
960                on_fail: None,
961                on_retry: None,
962                on_stop: None,
963                on_exit: None,
964                on_output: Some(crate::config_types::OnOutputHook {
965                    run: "curl http://localhost:{{ daemons.redis.port }}".into(),
966                    filter: Some("ready".to_string()),
967                    regex: None,
968                    debounce: None,
969                }),
970            }),
971            ..Default::default()
972        };
973
974        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
975
976        let hooks = config.hooks.unwrap();
977        let on_output = hooks.on_output.unwrap();
978        assert_eq!(on_output.run, "curl http://localhost:6379");
979        assert_eq!(on_output.filter.as_deref(), Some("ready"));
980    }
981
982    #[test]
983    fn test_render_daemon_templates_ready_cmd_array_per_argument() {
984        use crate::config_types::ReadyCmd;
985        use crate::pitchfork_toml::RunCommand;
986
987        let mut ctx = make_context_with_daemon("redis", vec![6379]);
988        let mut config = PitchforkTomlDaemon {
989            run: "echo".into(),
990            ready_cmd: Some(ReadyCmd::new(vec![
991                "redis-cli".to_string(),
992                "-p".to_string(),
993                "{{ daemons.redis.port }}".to_string(),
994                "ping {{ daemons.redis.port }}".to_string(),
995            ])),
996            ..Default::default()
997        };
998
999        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
1000
1001        // Each argument is rendered on its own, and a rendered value with a
1002        // space stays one argument.
1003        assert_eq!(
1004            config.ready_cmd.unwrap().run,
1005            RunCommand::Argv(vec![
1006                "redis-cli".to_string(),
1007                "-p".to_string(),
1008                "6379".to_string(),
1009                "ping 6379".to_string(),
1010            ])
1011        );
1012    }
1013
1014    #[test]
1015    fn test_render_daemon_templates_health_cmd_array_per_argument() {
1016        use crate::config_types::HealthCmd;
1017        use crate::pitchfork_toml::RunCommand;
1018
1019        let mut ctx = make_context_with_daemon("redis", vec![6379]);
1020        let mut config = PitchforkTomlDaemon {
1021            run: "echo".into(),
1022            health_cmd: Some(HealthCmd {
1023                run: RunCommand::Argv(vec![
1024                    "redis-cli".to_string(),
1025                    "-p".to_string(),
1026                    "{{ daemons.redis.port }}".to_string(),
1027                    "ping {{ daemons.redis.port }}".to_string(),
1028                ]),
1029                interval: Some(std::time::Duration::from_secs(5)),
1030                ..HealthCmd::default()
1031            }),
1032            ..Default::default()
1033        };
1034
1035        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
1036
1037        let health_cmd = config.health_cmd.unwrap();
1038        assert_eq!(
1039            health_cmd.run,
1040            RunCommand::Argv(vec![
1041                "redis-cli".to_string(),
1042                "-p".to_string(),
1043                "6379".to_string(),
1044                "ping 6379".to_string(),
1045            ])
1046        );
1047        assert_eq!(health_cmd.interval, Some(std::time::Duration::from_secs(5)));
1048    }
1049
1050    #[test]
1051    fn test_render_daemon_templates_ready_fields() {
1052        use crate::config_types::{ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort};
1053
1054        let mut ctx = make_context_with_daemon("redis", vec![6379]);
1055        let mut config = PitchforkTomlDaemon {
1056            run: "echo".into(),
1057            ready_cmd: Some(ReadyCmd::new("redis-cli -p {{ daemons.redis.port }} ping")),
1058            ready_output: Some(ReadyOutput::new("listening on {{ daemons.redis.port }}")),
1059            ready_http: Some(ReadyHttp {
1060                url: "http://localhost:{{ daemons.redis.port }}/health".to_string(),
1061                status: vec![200, 401],
1062                timeout: None,
1063            }),
1064            ready_port: Some(ReadyPort::from_template("{{ daemons.redis.port }}")),
1065            ..Default::default()
1066        };
1067
1068        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
1069
1070        assert_eq!(
1071            config.ready_cmd.as_ref().unwrap().run,
1072            "redis-cli -p 6379 ping"
1073        );
1074        assert_eq!(
1075            config.ready_output.as_ref().unwrap().pattern,
1076            "listening on 6379"
1077        );
1078        let http = config.ready_http.unwrap();
1079        assert_eq!(http.url, "http://localhost:6379/health");
1080        assert_eq!(http.status, vec![200, 401]);
1081        assert_eq!(config.ready_port, Some(ReadyPort::new(6379)));
1082    }
1083
1084    #[test]
1085    fn test_render_daemon_templates_health_fields() {
1086        use crate::config_types::{HealthCmd, HealthHttp};
1087        use std::time::Duration;
1088
1089        let mut ctx = make_context_with_daemon("redis", vec![6379]);
1090        let mut config = PitchforkTomlDaemon {
1091            run: "echo".into(),
1092            health_cmd: Some(HealthCmd {
1093                run: "redis-cli -p {{ daemons.redis.port }} ping".into(),
1094                interval: Some(Duration::from_secs(10)),
1095                timeout: None,
1096                retries: Some(3),
1097            }),
1098            health_http: Some(HealthHttp {
1099                url: "http://localhost:{{ daemons.redis.port }}/health".to_string(),
1100                status: vec![200],
1101                interval: None,
1102                timeout: Some(Duration::from_secs(5)),
1103                retries: None,
1104            }),
1105            ..Default::default()
1106        };
1107
1108        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
1109
1110        let hc = config.health_cmd.unwrap();
1111        assert_eq!(hc.run, "redis-cli -p 6379 ping");
1112        assert_eq!(hc.interval, Some(Duration::from_secs(10)));
1113        assert_eq!(hc.retries, Some(3));
1114        let hh = config.health_http.unwrap();
1115        assert_eq!(hh.url, "http://localhost:6379/health");
1116        assert_eq!(hh.status, vec![200]);
1117        assert_eq!(hh.timeout, Some(Duration::from_secs(5)));
1118    }
1119
1120    #[test]
1121    fn test_render_daemon_templates_ready_port_invalid() {
1122        use crate::config_types::ReadyPort;
1123
1124        let mut ctx = make_context_with_daemon("redis", vec![6379]);
1125        let mut config = PitchforkTomlDaemon {
1126            run: "echo".into(),
1127            ready_port: Some(ReadyPort::from_template("{{ name }}")),
1128            ..Default::default()
1129        };
1130
1131        let err = render_daemon_templates(&mut config, &mut ctx, None).unwrap_err();
1132        assert!(matches!(err, RenderError::InvalidPort { .. }));
1133    }
1134
1135    #[test]
1136    fn test_render_daemon_templates_ready_port_literal_untouched() {
1137        use crate::config_types::ReadyPort;
1138
1139        let mut ctx = make_context_with_daemon("redis", vec![6379]);
1140        let mut config = PitchforkTomlDaemon {
1141            run: "echo".into(),
1142            ready_port: Some(ReadyPort::new(8080)),
1143            ..Default::default()
1144        };
1145
1146        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
1147        assert_eq!(config.ready_port, Some(ReadyPort::new(8080)));
1148    }
1149
1150    #[test]
1151    fn test_render_daemon_templates_health_port() {
1152        use crate::config_types::HealthPort;
1153        use std::time::Duration;
1154
1155        let mut ctx = make_context_with_daemon("redis", vec![6379]);
1156        let mut config = PitchforkTomlDaemon {
1157            run: "echo".into(),
1158            health_port: Some(HealthPort {
1159                port: None,
1160                template: Some("{{ daemons.redis.port }}".to_string()),
1161                interval: Some(Duration::from_secs(10)),
1162                retries: Some(3),
1163                timeout: Some(Duration::from_secs(5)),
1164            }),
1165            ..Default::default()
1166        };
1167
1168        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
1169        let hp = config.health_port.unwrap();
1170        assert_eq!(hp.port, Some(6379));
1171        assert!(hp.template.is_none());
1172        assert_eq!(hp.interval, Some(Duration::from_secs(10)));
1173        assert_eq!(hp.retries, Some(3));
1174        assert_eq!(hp.timeout, Some(Duration::from_secs(5)));
1175    }
1176
1177    #[test]
1178    fn test_render_daemon_templates_health_port_invalid() {
1179        use crate::config_types::HealthPort;
1180
1181        let mut ctx = make_context_with_daemon("redis", vec![6379]);
1182        let mut config = PitchforkTomlDaemon {
1183            run: "echo".into(),
1184            health_port: Some(HealthPort::from_template("{{ name }}")),
1185            ..Default::default()
1186        };
1187
1188        let err = render_daemon_templates(&mut config, &mut ctx, None).unwrap_err();
1189        assert!(matches!(err, RenderError::InvalidPort { .. }));
1190    }
1191
1192    #[test]
1193    fn test_render_daemon_templates_health_port_literal_untouched() {
1194        use crate::config_types::HealthPort;
1195
1196        let mut ctx = make_context_with_daemon("redis", vec![6379]);
1197        let mut config = PitchforkTomlDaemon {
1198            run: "echo".into(),
1199            health_port: Some(HealthPort::new(8080)),
1200            ..Default::default()
1201        };
1202
1203        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
1204        assert_eq!(config.health_port, Some(HealthPort::new(8080)));
1205    }
1206
1207    #[test]
1208    fn test_render_daemon_templates_hook_error_does_not_fail() {
1209        let mut ctx = make_context_with_daemon("redis", vec![6379]);
1210        let mut config = PitchforkTomlDaemon {
1211            run: "echo".into(),
1212            hooks: Some(crate::config_types::PitchforkTomlHooks {
1213                on_ready: Some("{{ nonexistent }}".to_string()),
1214                on_fail: None,
1215                on_retry: None,
1216                on_stop: None,
1217                on_exit: None,
1218                on_output: None,
1219            }),
1220            ..Default::default()
1221        };
1222
1223        // Hook template errors are silently converted to None — daemon still starts
1224        render_daemon_templates(&mut config, &mut ctx, None).unwrap();
1225        let hooks = config.hooks.unwrap();
1226        assert!(hooks.on_ready.is_none());
1227    }
1228
1229    #[test]
1230    fn test_render_daemon_templates_run_error_still_fails() {
1231        let mut ctx = make_context_with_daemon("redis", vec![6379]);
1232        let mut config = PitchforkTomlDaemon {
1233            run: "{{ nonexistent }}".into(),
1234            ..Default::default()
1235        };
1236
1237        // Non-hook template errors still propagate as Err
1238        assert!(render_daemon_templates(&mut config, &mut ctx, None).is_err());
1239    }
1240}