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