1use 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#[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 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
42pub struct TemplateContext {
48 self_state: DaemonTemplateState,
49 daemon_states: HashMap<String, DaemonTemplateState>,
50 env: Option<IndexMap<String, String>>,
55}
56
57impl TemplateContext {
58 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 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 if dep_id.namespace() == id.namespace() {
124 daemon_states.insert(dep_id.name().to_string(), state.clone());
125 }
126
127 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 pub fn set_env(&mut self, env: IndexMap<String, String>) {
144 self.env = Some(env);
145 }
146
147 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 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 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 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 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 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 insert("url", serde_json::json!(proxy_url));
204
205 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
232fn qualified_key(id: &DaemonId) -> String {
235 format!("{}.{}", id.namespace(), id.name())
236}
237
238pub(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
261pub 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 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 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
317pub 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
340pub fn render_template(template: &str, context: &TemplateContext) -> Result<String, RenderError> {
349 TemplateRenderer::new(context).render(template)
350}
351
352pub 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
372fn 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 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 if let Some(ref env) = config.env {
390 context.set_env(env.clone());
391 }
392
393 let mut renderer = TemplateRenderer::new(context);
394
395 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 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 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
542pub const TEMPLATE_CONTEXT_ENV: &str = "PITCHFORK_TEMPLATE_CONTEXT";
545
546fn runs_under_mise(config: &PitchforkTomlDaemon) -> bool {
551 mise_enabled(config)
552}
553
554pub(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#[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#[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 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 #[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 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 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 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 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 assert_eq!(env["GRAM_HOST"], "0.0.0.0");
982 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 ("B".to_string(), "{{ env.A }}".to_string()),
1005 ]);
1006 let mut config = PitchforkTomlDaemon {
1007 run: "echo".into(),
1008 ..Default::default()
1009 };
1010 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 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 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 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}