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