1use crate::daemon_id::DaemonId;
12use crate::pitchfork_toml::PitchforkTomlDaemon;
13use crate::settings::settings;
14use indexmap::IndexMap;
15use std::collections::HashMap;
16use std::path::PathBuf;
17
18#[derive(Debug, Clone)]
24pub struct DaemonTemplateState {
25 pub ports: Vec<u16>,
26 pub id: String,
27 pub name: String,
28 pub namespace: String,
29 pub slug: Option<String>,
30 pub dir: PathBuf,
31}
32
33impl DaemonTemplateState {
34 fn port(&self) -> Option<u16> {
35 self.ports.first().copied()
36 }
37}
38
39pub struct TemplateContext {
45 self_state: DaemonTemplateState,
46 daemon_states: HashMap<String, DaemonTemplateState>,
47 env: Option<IndexMap<String, String>>,
52}
53
54impl TemplateContext {
55 pub fn new(
62 id: &DaemonId,
63 daemon_config: &PitchforkTomlDaemon,
64 resolved_daemons: &HashMap<DaemonId, Vec<u16>>,
65 daemon_configs: &IndexMap<DaemonId, PitchforkTomlDaemon>,
66 ) -> Self {
67 let global_slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
68 let effective_user = daemon_config.effective_user();
69 let dir = crate::ipc::batch::resolve_daemon_dir(
70 daemon_config.dir.as_deref(),
71 daemon_config.path.as_deref(),
72 effective_user.as_deref(),
73 );
74
75 let self_state = DaemonTemplateState {
76 ports: Vec::new(),
77 id: id.qualified(),
78 name: id.name().to_string(),
79 namespace: id.namespace().to_string(),
80 slug: crate::pitchfork_toml::PitchforkToml::find_slug_for_daemon_in_registry(
81 id,
82 &global_slugs,
83 ),
84 dir,
85 };
86
87 let mut daemon_states = HashMap::new();
88 for (dep_id, ports) in resolved_daemons {
89 if let Some(config) = daemon_configs.get(dep_id) {
90 let dep_effective_user = config.effective_user();
91 let dep_dir = crate::ipc::batch::resolve_daemon_dir(
92 config.dir.as_deref(),
93 config.path.as_deref(),
94 dep_effective_user.as_deref(),
95 );
96 let state = DaemonTemplateState {
97 ports: ports.clone(),
98 id: dep_id.qualified(),
99 name: dep_id.name().to_string(),
100 namespace: dep_id.namespace().to_string(),
101 slug: crate::pitchfork_toml::PitchforkToml::find_slug_for_daemon_in_registry(
102 dep_id,
103 &global_slugs,
104 ),
105 dir: dep_dir,
106 };
107
108 if dep_id.namespace() == id.namespace() {
110 daemon_states.insert(dep_id.name().to_string(), state.clone());
111 }
112
113 daemon_states.insert(qualified_key(dep_id), state);
115 }
116 }
117
118 Self {
119 self_state,
120 daemon_states,
121 env: None,
122 }
123 }
124
125 pub fn set_env(&mut self, env: IndexMap<String, String>) {
130 self.env = Some(env);
131 }
132
133 pub fn to_tera_context(&self) -> tera::Context {
135 let mut ctx = tera::Context::new();
136
137 ctx.insert("name", &self.self_state.name);
139 ctx.insert("namespace", &self.self_state.namespace);
140 ctx.insert("id", &self.self_state.id);
141 ctx.insert("slug", &self.self_state.slug);
142 ctx.insert("dir", &self.self_state.dir.to_string_lossy().to_string());
143
144 let mut daemons_map = serde_json::Map::new();
146 for (name, state) in &self.daemon_states {
147 if daemons_map.contains_key(name) {
148 continue;
149 }
150 daemons_map.insert(name.clone(), daemon_state_to_json(state));
151 }
152 ctx.insert("daemons", &serde_json::Value::Object(daemons_map));
153
154 let s = settings();
156 ctx.insert(
157 "settings",
158 &serde_json::json!({
159 "proxy": {
160 "enable": s.proxy.enable,
161 "tld": s.proxy.tld,
162 "port": s.proxy.port,
163 "https": s.proxy.https,
164 }
165 }),
166 );
167
168 let proxy_url = build_proxy_url(self.self_state.slug.as_deref(), &s);
171 ctx.insert("proxy_url", &proxy_url);
172
173 if let Some(ref env) = self.env {
175 let map: serde_json::Map<String, serde_json::Value> = env
176 .iter()
177 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
178 .collect();
179 ctx.insert("env", &serde_json::Value::Object(map));
180 }
181
182 ctx
183 }
184}
185
186fn daemon_state_to_json(state: &DaemonTemplateState) -> serde_json::Value {
187 serde_json::json!({
188 "port": state.port(),
189 "ports": state.ports,
190 "id": state.id,
191 "name": state.name,
192 "namespace": state.namespace,
193 "slug": state.slug,
194 "dir": state.dir.to_string_lossy(),
195 })
196}
197
198fn qualified_key(id: &DaemonId) -> String {
201 format!("{}.{}", id.namespace(), id.name())
202}
203
204fn build_proxy_url(slug: Option<&str>, s: &crate::settings::Settings) -> Option<String> {
206 let slug = slug?;
207 let scheme = if s.proxy.https { "https" } else { "http" };
208 let tld = &s.proxy.tld;
209 let standard_port = if s.proxy.https { 443u16 } else { 80u16 };
210 let effective_port = u16::try_from(s.proxy.port).ok().filter(|&p| p > 0)?;
211 let host = format!("{slug}.{tld}");
212 Some(if effective_port == standard_port {
213 format!("{scheme}://{host}")
214 } else {
215 format!("{scheme}://{host}:{effective_port}")
216 })
217}
218
219pub(crate) fn merge_env(
225 top: Option<&IndexMap<String, String>>,
226 daemon: Option<&IndexMap<String, String>>,
227) -> Option<IndexMap<String, String>> {
228 match (top, daemon) {
229 (None, None) => None,
230 (Some(t), None) => Some(t.clone()),
231 (None, Some(d)) => Some(d.clone()),
232 (Some(t), Some(d)) => {
233 let mut merged = t.clone();
234 for (k, v) in d {
235 merged.insert(k.clone(), v.clone());
236 }
237 Some(merged)
238 }
239 }
240}
241
242pub fn render_env(
250 top_env: Option<&IndexMap<String, String>>,
251 daemon_env: Option<&IndexMap<String, String>>,
252 context: &TemplateContext,
253) -> Result<Option<IndexMap<String, String>>, RenderError> {
254 let Some(merged) = merge_env(top_env, daemon_env) else {
255 return Ok(None);
256 };
257 let mut renderer = TemplateRenderer::new(context);
258 let rendered: IndexMap<String, String> = merged
259 .iter()
260 .map(|(k, v)| Ok((k.clone(), renderer.render(v)?)))
261 .collect::<Result<_, _>>()?;
262 Ok(Some(rendered))
263}
264
265pub fn render_template(template: &str, context: &TemplateContext) -> Result<String, RenderError> {
274 TemplateRenderer::new(context).render(template)
275}
276
277pub fn render_daemon_templates(
290 config: &mut PitchforkTomlDaemon,
291 context: &mut TemplateContext,
292 top_env: Option<&IndexMap<String, String>>,
293) -> Result<(), RenderError> {
294 let rendered_env = render_env(top_env, config.env.as_ref(), context)?;
299 config.env = rendered_env;
300
301 if let Some(ref env) = config.env {
304 context.set_env(env.clone());
305 }
306
307 let mut renderer = TemplateRenderer::new(context);
308
309 config.run = renderer.render(&config.run)?;
310
311 if let Some(ref hooks) = config.hooks {
312 let rendered = crate::config_types::PitchforkTomlHooks {
313 on_ready: hooks
314 .on_ready
315 .as_deref()
316 .and_then(|t| renderer.render(t).ok()),
317 on_fail: hooks
318 .on_fail
319 .as_deref()
320 .and_then(|t| renderer.render(t).ok()),
321 on_retry: hooks
322 .on_retry
323 .as_deref()
324 .and_then(|t| renderer.render(t).ok()),
325 on_stop: hooks
326 .on_stop
327 .as_deref()
328 .and_then(|t| renderer.render(t).ok()),
329 on_exit: hooks
330 .on_exit
331 .as_deref()
332 .and_then(|t| renderer.render(t).ok()),
333 on_output: hooks.on_output.as_ref().and_then(|hook| {
334 renderer
335 .render(&hook.run)
336 .ok()
337 .map(|run| crate::config_types::OnOutputHook {
338 run,
339 filter: hook.filter.clone(),
340 regex: hook.regex.clone(),
341 debounce: hook.debounce.clone(),
342 })
343 }),
344 };
345 config.hooks = Some(rendered);
346 }
347
348 if let Some(ref cmd) = config.ready_cmd {
349 config.ready_cmd = Some(crate::pitchfork_toml::ReadyCmd {
350 run: renderer.render(&cmd.run)?,
351 timeout: cmd.timeout,
352 });
353 }
354
355 if let Some(ref output) = config.ready_output {
356 let pattern = renderer.render(&output.pattern)?;
357 config.ready_output = Some(crate::config_types::ReadyOutput {
358 pattern,
359 timeout: output.timeout,
360 });
361 }
362
363 if let Some(ref http) = config.ready_http {
364 let mut http = http.clone();
365 http.url = renderer.render(&http.url)?;
366 config.ready_http = Some(http);
367 }
368
369 if let Some(ref ready_port) = config.ready_port
370 && let Some(ref template) = ready_port.template
371 {
372 let rendered = renderer.render(template)?;
373 let port = rendered
374 .trim()
375 .parse::<u16>()
376 .ok()
377 .filter(|&p| p > 0)
378 .ok_or_else(|| RenderError::InvalidPort {
379 template: template.clone(),
380 rendered: rendered.clone(),
381 })?;
382 config.ready_port = Some(crate::config_types::ReadyPort {
383 port: Some(port),
384 template: None,
385 timeout: ready_port.timeout,
386 });
387 }
388
389 Ok(())
390}
391
392fn contains_template_syntax(template: &str) -> bool {
393 template.contains("{{") || template.contains("{%") || template.contains("{#")
394}
395
396struct TemplateRenderer {
397 tera: tera::Tera,
398 context: tera::Context,
399 next_template_id: usize,
400}
401
402impl TemplateRenderer {
403 fn new(context: &TemplateContext) -> Self {
404 let mut tera = tera::Tera::default();
405 tera.register_filter(
406 "default",
407 |value: &tera::Value,
408 kwargs: tera::Kwargs,
409 _: &tera::State|
410 -> tera::TeraResult<tera::Value> {
411 let default_val = kwargs.must_get::<tera::Value>("value")?;
412 let boolean = kwargs.get::<bool>("boolean")?.unwrap_or_default();
413 if value.is_undefined() || value.is_none() || (boolean && !value.is_truthy()) {
414 Ok(default_val)
415 } else {
416 Ok(value.clone())
417 }
418 },
419 );
420 Self {
421 tera,
422 context: context.to_tera_context(),
423 next_template_id: 0,
424 }
425 }
426
427 fn render(&mut self, template: &str) -> Result<String, RenderError> {
428 if !contains_template_syntax(template) {
429 return Ok(template.to_string());
430 }
431
432 let template_name = format!("config_{}", self.next_template_id);
433 self.next_template_id += 1;
434
435 self.tera
436 .add_raw_template(&template_name, template)
437 .map_err(|e| RenderError::TemplateSyntax {
438 template: template.to_string(),
439 source: e,
440 })?;
441
442 self.tera
443 .render(&template_name, &self.context)
444 .map_err(|e| RenderError::RenderFailed {
445 template: template.to_string(),
446 source: e,
447 })
448 }
449}
450
451#[derive(Debug, thiserror::Error)]
456pub enum RenderError {
457 #[error("template syntax error in {template:?}: {source}")]
458 TemplateSyntax {
459 template: String,
460 source: tera::Error,
461 },
462 #[error("template render failed for {template:?}: {source}")]
463 RenderFailed {
464 template: String,
465 source: tera::Error,
466 },
467 #[error(
468 "ready_port template {template:?} rendered to {rendered:?}, expected a port number (1-65535)"
469 )]
470 InvalidPort { template: String, rendered: String },
471}
472
473#[cfg(test)]
478mod tests {
479 use super::*;
480
481 fn make_daemon_config(run: &str) -> PitchforkTomlDaemon {
482 PitchforkTomlDaemon {
483 run: run.to_string(),
484 ..Default::default()
485 }
486 }
487
488 fn make_context_with_daemon(name: &str, ports: Vec<u16>) -> TemplateContext {
489 let id = DaemonId::new("myproj", name);
490 let config = make_daemon_config("echo");
491 let mut resolved = HashMap::new();
492 resolved.insert(id.clone(), ports);
493 let mut configs = IndexMap::new();
494 configs.insert(id.clone(), make_daemon_config("echo"));
495 TemplateContext::new(
496 &DaemonId::new("myproj", "self"),
497 &config,
498 &resolved,
499 &configs,
500 )
501 }
502
503 #[test]
504 fn test_no_template_passthrough() {
505 let ctx = make_context_with_daemon("redis", vec![6379]);
506 assert_eq!(render_template("hello world", &ctx).unwrap(), "hello world");
507 }
508
509 #[test]
510 fn test_self_variables() {
511 let id = DaemonId::new("myproj", "api");
512 let config = make_daemon_config("echo");
513 let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
514
515 assert_eq!(render_template("{{ name }}", &ctx).unwrap(), "api");
516 assert_eq!(render_template("{{ namespace }}", &ctx).unwrap(), "myproj");
517 assert_eq!(render_template("{{ id }}", &ctx).unwrap(), "myproj/api");
518 }
519
520 #[test]
521 fn test_daemon_port_reference() {
522 let ctx = make_context_with_daemon("redis", vec![6379]);
523 assert_eq!(
524 render_template("{{ daemons.redis.port }}", &ctx).unwrap(),
525 "6379"
526 );
527 }
528
529 #[test]
530 fn test_daemon_ports_array() {
531 let ctx = make_context_with_daemon("redis", vec![6379, 6380]);
532 assert_eq!(
533 render_template("{{ daemons.redis.ports[0] }}", &ctx).unwrap(),
534 "6379"
535 );
536 assert_eq!(
537 render_template("{{ daemons.redis.ports[1] }}", &ctx).unwrap(),
538 "6380"
539 );
540 }
541
542 #[test]
543 fn test_daemon_qualified_name() {
544 let ctx = make_context_with_daemon("redis", vec![6379]);
545 assert_eq!(
546 render_template("{{ daemons[\"myproj.redis\"].port }}", &ctx).unwrap(),
547 "6379"
548 );
549 }
550
551 #[test]
552 fn test_short_name_only_matches_current_namespace() {
553 let self_id = DaemonId::new("app", "api");
554 let self_config = make_daemon_config("echo");
555 let other_id = DaemonId::new("infra", "redis");
556
557 let mut resolved = HashMap::new();
558 resolved.insert(other_id.clone(), vec![6379]);
559
560 let mut configs = IndexMap::new();
561 configs.insert(other_id.clone(), make_daemon_config("echo"));
562
563 let ctx = TemplateContext::new(&self_id, &self_config, &resolved, &configs);
564
565 assert!(render_template("{{ daemons.redis.port }}", &ctx).is_err());
566 assert_eq!(
567 render_template("{{ daemons[\"infra.redis\"].port }}", &ctx).unwrap(),
568 "6379"
569 );
570 }
571
572 #[test]
573 fn test_settings_reference() {
574 let ctx = make_context_with_daemon("redis", vec![6379]);
575 let result = render_template("{{ settings.proxy.tld }}", &ctx).unwrap();
576 assert_eq!(result, "localhost");
578 }
579
580 #[test]
581 fn test_undefined_variable_error() {
582 let ctx = make_context_with_daemon("redis", vec![6379]);
583 let result = render_template("{{ nonexistent }}", &ctx);
584 assert!(result.is_err());
585 }
586
587 #[test]
588 fn test_comment_only_template_is_parsed() {
589 let ctx = make_context_with_daemon("redis", vec![6379]);
590 assert_eq!(
591 render_template("before{# hidden #}after", &ctx).unwrap(),
592 "beforeafter"
593 );
594 }
595
596 #[test]
597 fn test_proxy_url_is_present_as_null_when_slug_is_missing() {
598 let id = DaemonId::new("myproj", "api");
599 let config = make_daemon_config("echo");
600 let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
601
602 assert_eq!(
603 render_template("{{ proxy_url | default(value=\"none\") }}", &ctx).unwrap(),
604 "none"
605 );
606 }
607
608 #[test]
609 fn test_mixed_template_and_literal() {
610 let ctx = make_context_with_daemon("redis", vec![6379]);
611 assert_eq!(
612 render_template("redis://localhost:{{ daemons.redis.port }}/0", &ctx).unwrap(),
613 "redis://localhost:6379/0"
614 );
615 }
616
617 #[test]
618 fn test_render_daemon_templates_run() {
619 let mut ctx = make_context_with_daemon("redis", vec![6379]);
620 let mut config = PitchforkTomlDaemon {
621 run: "redis-cli -p {{ daemons.redis.port }}".to_string(),
622 ..Default::default()
623 };
624 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
625 assert_eq!(config.run, "redis-cli -p 6379");
626 }
627
628 #[test]
629 fn test_render_daemon_templates_env() {
630 let mut ctx = make_context_with_daemon("redis", vec![6379]);
631 let mut config = PitchforkTomlDaemon {
632 run: "echo".to_string(),
633 env: Some(IndexMap::from([
634 (
635 "DATABASE_URL".to_string(),
636 "redis://localhost:{{ daemons.redis.port }}/0".to_string(),
637 ),
638 ("STATIC_VAR".to_string(), "unchanged".to_string()),
639 ])),
640 ..Default::default()
641 };
642 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
643 let env = config.env.unwrap();
644 assert_eq!(env["DATABASE_URL"], "redis://localhost:6379/0");
645 assert_eq!(env["STATIC_VAR"], "unchanged");
646 }
647
648 #[test]
649 fn test_top_level_env_merged_as_defaults() {
650 let mut ctx = make_context_with_daemon("redis", vec![6379]);
651 let top_env = IndexMap::from([
652 ("GRAM_HOST".to_string(), "localhost".to_string()),
653 (
654 "GRAM_URL".to_string(),
655 "redis://{{ daemons.redis.port }}".to_string(),
656 ),
657 ]);
658 let mut config = PitchforkTomlDaemon {
659 run: "echo".to_string(),
660 env: Some(IndexMap::from([(
661 "GRAM_HOST".to_string(),
662 "0.0.0.0".to_string(),
663 )])),
664 ..Default::default()
665 };
666 render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).unwrap();
667 let env = config.env.unwrap();
668 assert_eq!(env["GRAM_HOST"], "0.0.0.0");
670 assert_eq!(env["GRAM_URL"], "redis://6379");
672 }
673
674 #[test]
675 fn test_env_exposed_in_context_for_other_fields() {
676 let mut ctx = make_context_with_daemon("redis", vec![6379]);
677 let top_env = IndexMap::from([("GRAM_HOST".to_string(), "localhost".to_string())]);
678 let mut config = PitchforkTomlDaemon {
679 run: "echo {{ env.GRAM_HOST }}".to_string(),
680 ..Default::default()
681 };
682 render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).unwrap();
683 assert_eq!(config.run, "echo localhost");
684 }
685
686 #[test]
687 fn test_env_values_cannot_self_reference() {
688 let mut ctx = make_context_with_daemon("redis", vec![6379]);
689 let top_env = IndexMap::from([
690 ("A".to_string(), "value-a".to_string()),
691 ("B".to_string(), "{{ env.A }}".to_string()),
693 ]);
694 let mut config = PitchforkTomlDaemon {
695 run: "echo".to_string(),
696 ..Default::default()
697 };
698 assert!(render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).is_err());
701 }
702
703 #[test]
704 fn test_render_daemon_templates_on_output_run() {
705 let mut ctx = make_context_with_daemon("redis", vec![6379]);
706 let mut config = PitchforkTomlDaemon {
707 run: "echo".to_string(),
708 hooks: Some(crate::config_types::PitchforkTomlHooks {
709 on_ready: None,
710 on_fail: None,
711 on_retry: None,
712 on_stop: None,
713 on_exit: None,
714 on_output: Some(crate::config_types::OnOutputHook {
715 run: "curl http://localhost:{{ daemons.redis.port }}".to_string(),
716 filter: Some("ready".to_string()),
717 regex: None,
718 debounce: None,
719 }),
720 }),
721 ..Default::default()
722 };
723
724 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
725
726 let hooks = config.hooks.unwrap();
727 let on_output = hooks.on_output.unwrap();
728 assert_eq!(on_output.run, "curl http://localhost:6379");
729 assert_eq!(on_output.filter.as_deref(), Some("ready"));
730 }
731
732 #[test]
733 fn test_render_daemon_templates_ready_fields() {
734 use crate::config_types::{ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort};
735
736 let mut ctx = make_context_with_daemon("redis", vec![6379]);
737 let mut config = PitchforkTomlDaemon {
738 run: "echo".to_string(),
739 ready_cmd: Some(ReadyCmd::new("redis-cli -p {{ daemons.redis.port }} ping")),
740 ready_output: Some(ReadyOutput::new("listening on {{ daemons.redis.port }}")),
741 ready_http: Some(ReadyHttp {
742 url: "http://localhost:{{ daemons.redis.port }}/health".to_string(),
743 status: vec![200, 401],
744 timeout: None,
745 }),
746 ready_port: Some(ReadyPort::from_template("{{ daemons.redis.port }}")),
747 ..Default::default()
748 };
749
750 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
751
752 assert_eq!(
753 config.ready_cmd.as_ref().unwrap().run,
754 "redis-cli -p 6379 ping"
755 );
756 assert_eq!(
757 config.ready_output.as_ref().unwrap().pattern,
758 "listening on 6379"
759 );
760 let http = config.ready_http.unwrap();
761 assert_eq!(http.url, "http://localhost:6379/health");
762 assert_eq!(http.status, vec![200, 401]);
763 assert_eq!(config.ready_port, Some(ReadyPort::new(6379)));
764 }
765
766 #[test]
767 fn test_render_daemon_templates_ready_port_invalid() {
768 use crate::config_types::ReadyPort;
769
770 let mut ctx = make_context_with_daemon("redis", vec![6379]);
771 let mut config = PitchforkTomlDaemon {
772 run: "echo".to_string(),
773 ready_port: Some(ReadyPort::from_template("{{ name }}")),
774 ..Default::default()
775 };
776
777 let err = render_daemon_templates(&mut config, &mut ctx, None).unwrap_err();
778 assert!(matches!(err, RenderError::InvalidPort { .. }));
779 }
780
781 #[test]
782 fn test_render_daemon_templates_ready_port_literal_untouched() {
783 use crate::config_types::ReadyPort;
784
785 let mut ctx = make_context_with_daemon("redis", vec![6379]);
786 let mut config = PitchforkTomlDaemon {
787 run: "echo".to_string(),
788 ready_port: Some(ReadyPort::new(8080)),
789 ..Default::default()
790 };
791
792 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
793 assert_eq!(config.ready_port, Some(ReadyPort::new(8080)));
794 }
795
796 #[test]
797 fn test_render_daemon_templates_hook_error_does_not_fail() {
798 let mut ctx = make_context_with_daemon("redis", vec![6379]);
799 let mut config = PitchforkTomlDaemon {
800 run: "echo".to_string(),
801 hooks: Some(crate::config_types::PitchforkTomlHooks {
802 on_ready: Some("{{ nonexistent }}".to_string()),
803 on_fail: None,
804 on_retry: None,
805 on_stop: None,
806 on_exit: None,
807 on_output: None,
808 }),
809 ..Default::default()
810 };
811
812 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
814 let hooks = config.hooks.unwrap();
815 assert!(hooks.on_ready.is_none());
816 }
817
818 #[test]
819 fn test_render_daemon_templates_run_error_still_fails() {
820 let mut ctx = make_context_with_daemon("redis", vec![6379]);
821 let mut config = PitchforkTomlDaemon {
822 run: "{{ nonexistent }}".to_string(),
823 ..Default::default()
824 };
825
826 assert!(render_daemon_templates(&mut config, &mut ctx, None).is_err());
828 }
829}