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 dir: PathBuf,
32}
33
34impl DaemonTemplateState {
35 fn port(&self) -> Option<u16> {
36 self.ports.first().copied()
37 }
38}
39
40pub struct TemplateContext {
46 self_state: DaemonTemplateState,
47 daemon_states: HashMap<String, DaemonTemplateState>,
48 env: Option<IndexMap<String, String>>,
53}
54
55impl TemplateContext {
56 pub fn new(
63 id: &DaemonId,
64 daemon_config: &PitchforkTomlDaemon,
65 resolved_daemons: &HashMap<DaemonId, Vec<u16>>,
66 daemon_configs: &IndexMap<DaemonId, PitchforkTomlDaemon>,
67 ) -> Self {
68 let global_slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
69 let effective_user = daemon_config.effective_user();
70 let dir = crate::ipc::batch::resolve_daemon_dir(
71 daemon_config.dir.as_deref(),
72 daemon_config.path.as_deref(),
73 effective_user.as_deref(),
74 );
75
76 let self_state = DaemonTemplateState {
77 ports: Vec::new(),
78 id: id.qualified(),
79 name: id.name().to_string(),
80 namespace: id.namespace().to_string(),
81 slug: crate::pitchfork_toml::PitchforkToml::find_slug_for_daemon_in_registry(
82 id,
83 &global_slugs,
84 ),
85 dir,
86 };
87
88 let mut daemon_states = HashMap::new();
89 for (dep_id, ports) in resolved_daemons {
90 if let Some(config) = daemon_configs.get(dep_id) {
91 let dep_effective_user = config.effective_user();
92 let dep_dir = crate::ipc::batch::resolve_daemon_dir(
93 config.dir.as_deref(),
94 config.path.as_deref(),
95 dep_effective_user.as_deref(),
96 );
97 let state = DaemonTemplateState {
98 ports: ports.clone(),
99 id: dep_id.qualified(),
100 name: dep_id.name().to_string(),
101 namespace: dep_id.namespace().to_string(),
102 slug: crate::pitchfork_toml::PitchforkToml::find_slug_for_daemon_in_registry(
103 dep_id,
104 &global_slugs,
105 ),
106 dir: dep_dir,
107 };
108
109 if dep_id.namespace() == id.namespace() {
111 daemon_states.insert(dep_id.name().to_string(), state.clone());
112 }
113
114 daemon_states.insert(qualified_key(dep_id), state);
116 }
117 }
118
119 Self {
120 self_state,
121 daemon_states,
122 env: None,
123 }
124 }
125
126 pub fn set_env(&mut self, env: IndexMap<String, String>) {
131 self.env = Some(env);
132 }
133
134 pub fn to_tera_context(&self) -> tera::Context {
136 let mut ctx = tera::Context::new();
137
138 ctx.insert("name", &self.self_state.name);
140 ctx.insert("namespace", &self.self_state.namespace);
141 ctx.insert("id", &self.self_state.id);
142 ctx.insert("slug", &self.self_state.slug);
143 ctx.insert("dir", &self.self_state.dir.to_string_lossy().to_string());
144
145 let mut daemons_map = serde_json::Map::new();
147 for (name, state) in &self.daemon_states {
148 if daemons_map.contains_key(name) {
149 continue;
150 }
151 daemons_map.insert(name.clone(), daemon_state_to_json(state));
152 }
153 ctx.insert("daemons", &serde_json::Value::Object(daemons_map));
154
155 let s = settings();
157 ctx.insert(
158 "settings",
159 &serde_json::json!({
160 "proxy": {
161 "enable": s.proxy.enable,
162 "tld": s.proxy.tld,
163 "port": s.proxy.port,
164 "https": s.proxy.https,
165 }
166 }),
167 );
168
169 let proxy_url = build_proxy_url(self.self_state.slug.as_deref(), &s);
172 ctx.insert("proxy_url", &proxy_url);
173
174 if let Some(ref env) = self.env {
176 let map: serde_json::Map<String, serde_json::Value> = env
177 .iter()
178 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
179 .collect();
180 ctx.insert("env", &serde_json::Value::Object(map));
181 }
182
183 ctx
184 }
185}
186
187fn daemon_state_to_json(state: &DaemonTemplateState) -> serde_json::Value {
188 serde_json::json!({
189 "port": state.port(),
190 "ports": state.ports,
191 "id": state.id,
192 "name": state.name,
193 "namespace": state.namespace,
194 "slug": state.slug,
195 "dir": state.dir.to_string_lossy(),
196 })
197}
198
199fn qualified_key(id: &DaemonId) -> String {
202 format!("{}.{}", id.namespace(), id.name())
203}
204
205fn build_proxy_url(slug: Option<&str>, s: &crate::settings::Settings) -> Option<String> {
207 let slug = slug?;
208 let scheme = if s.proxy.https { "https" } else { "http" };
209 let tld = &s.proxy.tld;
210 let standard_port = if s.proxy.https { 443u16 } else { 80u16 };
211 let effective_port = u16::try_from(s.proxy.port).ok().filter(|&p| p > 0)?;
212 let host = format!("{slug}.{tld}");
213 Some(if effective_port == standard_port {
214 format!("{scheme}://{host}")
215 } else {
216 format!("{scheme}://{host}:{effective_port}")
217 })
218}
219
220pub(crate) fn merge_env(
226 top: Option<&IndexMap<String, String>>,
227 daemon: Option<&IndexMap<String, String>>,
228) -> Option<IndexMap<String, String>> {
229 match (top, daemon) {
230 (None, None) => None,
231 (Some(t), None) => Some(t.clone()),
232 (None, Some(d)) => Some(d.clone()),
233 (Some(t), Some(d)) => {
234 let mut merged = t.clone();
235 for (k, v) in d {
236 merged.insert(k.clone(), v.clone());
237 }
238 Some(merged)
239 }
240 }
241}
242
243pub fn render_env(
251 top_env: Option<&IndexMap<String, String>>,
252 daemon_env: Option<&IndexMap<String, String>>,
253 context: &TemplateContext,
254) -> Result<Option<IndexMap<String, String>>, RenderError> {
255 let Some(merged) = merge_env(top_env, daemon_env) else {
256 return Ok(None);
257 };
258 let mut renderer = TemplateRenderer::new(context);
259 let rendered: IndexMap<String, String> = merged
260 .iter()
261 .map(|(k, v)| Ok((k.clone(), renderer.render(v)?)))
262 .collect::<Result<_, _>>()?;
263 Ok(Some(rendered))
264}
265
266pub fn render_template(template: &str, context: &TemplateContext) -> Result<String, RenderError> {
275 TemplateRenderer::new(context).render(template)
276}
277
278pub fn render_daemon_templates(
291 config: &mut PitchforkTomlDaemon,
292 context: &mut TemplateContext,
293 top_env: Option<&IndexMap<String, String>>,
294) -> Result<(), RenderError> {
295 let rendered_env = render_env(top_env, config.env.as_ref(), context)?;
300 config.env = rendered_env;
301
302 if let Some(ref env) = config.env {
305 context.set_env(env.clone());
306 }
307
308 let mut renderer = TemplateRenderer::new(context);
309
310 config.run = renderer.render(&config.run)?;
311
312 if let Some(ref hooks) = config.hooks {
313 let rendered = crate::config_types::PitchforkTomlHooks {
314 on_ready: hooks
315 .on_ready
316 .as_deref()
317 .and_then(|t| renderer.render(t).ok()),
318 on_fail: hooks
319 .on_fail
320 .as_deref()
321 .and_then(|t| renderer.render(t).ok()),
322 on_retry: hooks
323 .on_retry
324 .as_deref()
325 .and_then(|t| renderer.render(t).ok()),
326 on_stop: hooks
327 .on_stop
328 .as_deref()
329 .and_then(|t| renderer.render(t).ok()),
330 on_exit: hooks
331 .on_exit
332 .as_deref()
333 .and_then(|t| renderer.render(t).ok()),
334 on_output: hooks.on_output.as_ref().and_then(|hook| {
335 renderer
336 .render(&hook.run)
337 .ok()
338 .map(|run| crate::config_types::OnOutputHook {
339 run,
340 filter: hook.filter.clone(),
341 regex: hook.regex.clone(),
342 debounce: hook.debounce.clone(),
343 })
344 }),
345 };
346 config.hooks = Some(rendered);
347 }
348
349 if let Some(ref cmd) = config.ready_cmd {
350 config.ready_cmd = Some(crate::pitchfork_toml::ReadyCmd {
351 run: renderer.render(&cmd.run)?,
352 timeout: cmd.timeout,
353 });
354 }
355
356 if let Some(ref hc) = config.health_cmd {
357 config.health_cmd = Some(crate::pitchfork_toml::HealthCmd {
358 run: renderer.render(&hc.run)?,
359 interval: hc.interval,
360 timeout: hc.timeout,
361 retries: hc.retries,
362 });
363 }
364
365 if let Some(ref http) = config.health_http {
366 let mut http = http.clone();
367 http.url = renderer.render(&http.url)?;
368 config.health_http = Some(http);
369 }
370
371 if let Some(ref health_port) = config.health_port
372 && let Some(ref template) = health_port.template
373 {
374 let rendered = renderer.render(template)?;
375 let port = rendered
376 .trim()
377 .parse::<u16>()
378 .ok()
379 .filter(|&p| p > 0)
380 .ok_or_else(|| RenderError::InvalidPort {
381 field: "health_port",
382 template: template.clone(),
383 rendered: rendered.clone(),
384 })?;
385 config.health_port = Some(crate::config_types::HealthPort {
386 port: Some(port),
387 template: None,
388 interval: health_port.interval,
389 retries: health_port.retries,
390 timeout: health_port.timeout,
391 });
392 }
393
394 if let Some(ref output) = config.ready_output {
395 let pattern = renderer.render(&output.pattern)?;
396 config.ready_output = Some(crate::config_types::ReadyOutput {
397 pattern,
398 timeout: output.timeout,
399 });
400 }
401
402 if let Some(ref http) = config.ready_http {
403 let mut http = http.clone();
404 http.url = renderer.render(&http.url)?;
405 config.ready_http = Some(http);
406 }
407
408 if let Some(ref ready_port) = config.ready_port
409 && let Some(ref template) = ready_port.template
410 {
411 let rendered = renderer.render(template)?;
412 let port = rendered
413 .trim()
414 .parse::<u16>()
415 .ok()
416 .filter(|&p| p > 0)
417 .ok_or_else(|| RenderError::InvalidPort {
418 field: "ready_port",
419 template: template.clone(),
420 rendered: rendered.clone(),
421 })?;
422 config.ready_port = Some(crate::config_types::ReadyPort {
423 port: Some(port),
424 template: None,
425 timeout: ready_port.timeout,
426 });
427 }
428
429 Ok(())
430}
431
432fn contains_template_syntax(template: &str) -> bool {
433 template.contains("{{") || template.contains("{%") || template.contains("{#")
434}
435
436struct TemplateRenderer {
437 tera: tera::Tera,
438 context: tera::Context,
439 next_template_id: usize,
440}
441
442impl TemplateRenderer {
443 fn new(context: &TemplateContext) -> Self {
444 let mut tera = tera::Tera::default();
445 tera.register_filter(
446 "default",
447 |value: &tera::Value,
448 kwargs: tera::Kwargs,
449 _: &tera::State|
450 -> tera::TeraResult<tera::Value> {
451 let default_val = kwargs.must_get::<tera::Value>("value")?;
452 let boolean = kwargs.get::<bool>("boolean")?.unwrap_or_default();
453 if value.is_undefined() || value.is_none() || (boolean && !value.is_truthy()) {
454 Ok(default_val)
455 } else {
456 Ok(value.clone())
457 }
458 },
459 );
460 Self {
461 tera,
462 context: context.to_tera_context(),
463 next_template_id: 0,
464 }
465 }
466
467 fn render(&mut self, template: &str) -> Result<String, RenderError> {
468 if !contains_template_syntax(template) {
469 return Ok(template.to_string());
470 }
471
472 let template_name = format!("config_{}", self.next_template_id);
473 self.next_template_id += 1;
474
475 self.tera
476 .add_raw_template(&template_name, template)
477 .map_err(|e| RenderError::TemplateSyntax {
478 template: template.to_string(),
479 source: e,
480 })?;
481
482 self.tera
483 .render(&template_name, &self.context)
484 .map_err(|e| RenderError::RenderFailed {
485 template: template.to_string(),
486 source: e,
487 })
488 }
489}
490
491#[derive(Debug, thiserror::Error)]
496pub enum RenderError {
497 #[error("template syntax error in {template:?}: {source}")]
498 TemplateSyntax {
499 template: String,
500 source: tera::Error,
501 },
502 #[error("template render failed for {template:?}: {source}")]
503 RenderFailed {
504 template: String,
505 source: tera::Error,
506 },
507 #[error(
508 "{field} template {template:?} rendered to {rendered:?}, expected a port number (1-65535)"
509 )]
510 InvalidPort {
511 field: &'static str,
512 template: String,
513 rendered: String,
514 },
515}
516
517#[cfg(test)]
522mod tests {
523 use super::*;
524
525 fn make_daemon_config(run: &str) -> PitchforkTomlDaemon {
526 PitchforkTomlDaemon {
527 run: run.to_string(),
528 ..Default::default()
529 }
530 }
531
532 fn make_context_with_daemon(name: &str, ports: Vec<u16>) -> TemplateContext {
533 let id = DaemonId::new("myproj", name);
534 let config = make_daemon_config("echo");
535 let mut resolved = HashMap::new();
536 resolved.insert(id.clone(), ports);
537 let mut configs = IndexMap::new();
538 configs.insert(id.clone(), make_daemon_config("echo"));
539 TemplateContext::new(
540 &DaemonId::new("myproj", "self"),
541 &config,
542 &resolved,
543 &configs,
544 )
545 }
546
547 #[test]
548 fn test_no_template_passthrough() {
549 let ctx = make_context_with_daemon("redis", vec![6379]);
550 assert_eq!(render_template("hello world", &ctx).unwrap(), "hello world");
551 }
552
553 #[test]
554 fn test_self_variables() {
555 let id = DaemonId::new("myproj", "api");
556 let config = make_daemon_config("echo");
557 let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
558
559 assert_eq!(render_template("{{ name }}", &ctx).unwrap(), "api");
560 assert_eq!(render_template("{{ namespace }}", &ctx).unwrap(), "myproj");
561 assert_eq!(render_template("{{ id }}", &ctx).unwrap(), "myproj/api");
562 }
563
564 #[test]
565 fn test_daemon_port_reference() {
566 let ctx = make_context_with_daemon("redis", vec![6379]);
567 assert_eq!(
568 render_template("{{ daemons.redis.port }}", &ctx).unwrap(),
569 "6379"
570 );
571 }
572
573 #[test]
574 fn test_daemon_ports_array() {
575 let ctx = make_context_with_daemon("redis", vec![6379, 6380]);
576 assert_eq!(
577 render_template("{{ daemons.redis.ports[0] }}", &ctx).unwrap(),
578 "6379"
579 );
580 assert_eq!(
581 render_template("{{ daemons.redis.ports[1] }}", &ctx).unwrap(),
582 "6380"
583 );
584 }
585
586 #[test]
587 fn test_daemon_qualified_name() {
588 let ctx = make_context_with_daemon("redis", vec![6379]);
589 assert_eq!(
590 render_template("{{ daemons[\"myproj.redis\"].port }}", &ctx).unwrap(),
591 "6379"
592 );
593 }
594
595 #[test]
596 fn test_short_name_only_matches_current_namespace() {
597 let self_id = DaemonId::new("app", "api");
598 let self_config = make_daemon_config("echo");
599 let other_id = DaemonId::new("infra", "redis");
600
601 let mut resolved = HashMap::new();
602 resolved.insert(other_id.clone(), vec![6379]);
603
604 let mut configs = IndexMap::new();
605 configs.insert(other_id.clone(), make_daemon_config("echo"));
606
607 let ctx = TemplateContext::new(&self_id, &self_config, &resolved, &configs);
608
609 assert!(render_template("{{ daemons.redis.port }}", &ctx).is_err());
610 assert_eq!(
611 render_template("{{ daemons[\"infra.redis\"].port }}", &ctx).unwrap(),
612 "6379"
613 );
614 }
615
616 #[test]
617 fn test_settings_reference() {
618 let ctx = make_context_with_daemon("redis", vec![6379]);
619 let result = render_template("{{ settings.proxy.tld }}", &ctx).unwrap();
620 assert_eq!(result, "localhost");
622 }
623
624 #[test]
625 fn test_undefined_variable_error() {
626 let ctx = make_context_with_daemon("redis", vec![6379]);
627 let result = render_template("{{ nonexistent }}", &ctx);
628 assert!(result.is_err());
629 }
630
631 #[test]
632 fn test_comment_only_template_is_parsed() {
633 let ctx = make_context_with_daemon("redis", vec![6379]);
634 assert_eq!(
635 render_template("before{# hidden #}after", &ctx).unwrap(),
636 "beforeafter"
637 );
638 }
639
640 #[test]
641 fn test_proxy_url_is_present_as_null_when_slug_is_missing() {
642 let id = DaemonId::new("myproj", "api");
643 let config = make_daemon_config("echo");
644 let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
645
646 assert_eq!(
647 render_template("{{ proxy_url | default(value=\"none\") }}", &ctx).unwrap(),
648 "none"
649 );
650 }
651
652 #[test]
653 fn test_mixed_template_and_literal() {
654 let ctx = make_context_with_daemon("redis", vec![6379]);
655 assert_eq!(
656 render_template("redis://localhost:{{ daemons.redis.port }}/0", &ctx).unwrap(),
657 "redis://localhost:6379/0"
658 );
659 }
660
661 #[test]
662 fn test_render_daemon_templates_run() {
663 let mut ctx = make_context_with_daemon("redis", vec![6379]);
664 let mut config = PitchforkTomlDaemon {
665 run: "redis-cli -p {{ daemons.redis.port }}".to_string(),
666 ..Default::default()
667 };
668 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
669 assert_eq!(config.run, "redis-cli -p 6379");
670 }
671
672 #[test]
673 fn test_render_daemon_templates_env() {
674 let mut ctx = make_context_with_daemon("redis", vec![6379]);
675 let mut config = PitchforkTomlDaemon {
676 run: "echo".to_string(),
677 env: Some(IndexMap::from([
678 (
679 "DATABASE_URL".to_string(),
680 "redis://localhost:{{ daemons.redis.port }}/0".to_string(),
681 ),
682 ("STATIC_VAR".to_string(), "unchanged".to_string()),
683 ])),
684 ..Default::default()
685 };
686 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
687 let env = config.env.unwrap();
688 assert_eq!(env["DATABASE_URL"], "redis://localhost:6379/0");
689 assert_eq!(env["STATIC_VAR"], "unchanged");
690 }
691
692 #[test]
693 fn test_top_level_env_merged_as_defaults() {
694 let mut ctx = make_context_with_daemon("redis", vec![6379]);
695 let top_env = IndexMap::from([
696 ("GRAM_HOST".to_string(), "localhost".to_string()),
697 (
698 "GRAM_URL".to_string(),
699 "redis://{{ daemons.redis.port }}".to_string(),
700 ),
701 ]);
702 let mut config = PitchforkTomlDaemon {
703 run: "echo".to_string(),
704 env: Some(IndexMap::from([(
705 "GRAM_HOST".to_string(),
706 "0.0.0.0".to_string(),
707 )])),
708 ..Default::default()
709 };
710 render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).unwrap();
711 let env = config.env.unwrap();
712 assert_eq!(env["GRAM_HOST"], "0.0.0.0");
714 assert_eq!(env["GRAM_URL"], "redis://6379");
716 }
717
718 #[test]
719 fn test_env_exposed_in_context_for_other_fields() {
720 let mut ctx = make_context_with_daemon("redis", vec![6379]);
721 let top_env = IndexMap::from([("GRAM_HOST".to_string(), "localhost".to_string())]);
722 let mut config = PitchforkTomlDaemon {
723 run: "echo {{ env.GRAM_HOST }}".to_string(),
724 ..Default::default()
725 };
726 render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).unwrap();
727 assert_eq!(config.run, "echo localhost");
728 }
729
730 #[test]
731 fn test_env_values_cannot_self_reference() {
732 let mut ctx = make_context_with_daemon("redis", vec![6379]);
733 let top_env = IndexMap::from([
734 ("A".to_string(), "value-a".to_string()),
735 ("B".to_string(), "{{ env.A }}".to_string()),
737 ]);
738 let mut config = PitchforkTomlDaemon {
739 run: "echo".to_string(),
740 ..Default::default()
741 };
742 assert!(render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).is_err());
745 }
746
747 #[test]
748 fn test_render_daemon_templates_on_output_run() {
749 let mut ctx = make_context_with_daemon("redis", vec![6379]);
750 let mut config = PitchforkTomlDaemon {
751 run: "echo".to_string(),
752 hooks: Some(crate::config_types::PitchforkTomlHooks {
753 on_ready: None,
754 on_fail: None,
755 on_retry: None,
756 on_stop: None,
757 on_exit: None,
758 on_output: Some(crate::config_types::OnOutputHook {
759 run: "curl http://localhost:{{ daemons.redis.port }}".to_string(),
760 filter: Some("ready".to_string()),
761 regex: None,
762 debounce: None,
763 }),
764 }),
765 ..Default::default()
766 };
767
768 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
769
770 let hooks = config.hooks.unwrap();
771 let on_output = hooks.on_output.unwrap();
772 assert_eq!(on_output.run, "curl http://localhost:6379");
773 assert_eq!(on_output.filter.as_deref(), Some("ready"));
774 }
775
776 #[test]
777 fn test_render_daemon_templates_ready_fields() {
778 use crate::config_types::{ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort};
779
780 let mut ctx = make_context_with_daemon("redis", vec![6379]);
781 let mut config = PitchforkTomlDaemon {
782 run: "echo".to_string(),
783 ready_cmd: Some(ReadyCmd::new("redis-cli -p {{ daemons.redis.port }} ping")),
784 ready_output: Some(ReadyOutput::new("listening on {{ daemons.redis.port }}")),
785 ready_http: Some(ReadyHttp {
786 url: "http://localhost:{{ daemons.redis.port }}/health".to_string(),
787 status: vec![200, 401],
788 timeout: None,
789 }),
790 ready_port: Some(ReadyPort::from_template("{{ daemons.redis.port }}")),
791 ..Default::default()
792 };
793
794 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
795
796 assert_eq!(
797 config.ready_cmd.as_ref().unwrap().run,
798 "redis-cli -p 6379 ping"
799 );
800 assert_eq!(
801 config.ready_output.as_ref().unwrap().pattern,
802 "listening on 6379"
803 );
804 let http = config.ready_http.unwrap();
805 assert_eq!(http.url, "http://localhost:6379/health");
806 assert_eq!(http.status, vec![200, 401]);
807 assert_eq!(config.ready_port, Some(ReadyPort::new(6379)));
808 }
809
810 #[test]
811 fn test_render_daemon_templates_health_fields() {
812 use crate::config_types::{HealthCmd, HealthHttp};
813 use std::time::Duration;
814
815 let mut ctx = make_context_with_daemon("redis", vec![6379]);
816 let mut config = PitchforkTomlDaemon {
817 run: "echo".to_string(),
818 health_cmd: Some(HealthCmd {
819 run: "redis-cli -p {{ daemons.redis.port }} ping".to_string(),
820 interval: Some(Duration::from_secs(10)),
821 timeout: None,
822 retries: Some(3),
823 }),
824 health_http: Some(HealthHttp {
825 url: "http://localhost:{{ daemons.redis.port }}/health".to_string(),
826 status: vec![200],
827 interval: None,
828 timeout: Some(Duration::from_secs(5)),
829 retries: None,
830 }),
831 ..Default::default()
832 };
833
834 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
835
836 let hc = config.health_cmd.unwrap();
837 assert_eq!(hc.run, "redis-cli -p 6379 ping");
838 assert_eq!(hc.interval, Some(Duration::from_secs(10)));
839 assert_eq!(hc.retries, Some(3));
840 let hh = config.health_http.unwrap();
841 assert_eq!(hh.url, "http://localhost:6379/health");
842 assert_eq!(hh.status, vec![200]);
843 assert_eq!(hh.timeout, Some(Duration::from_secs(5)));
844 }
845
846 #[test]
847 fn test_render_daemon_templates_ready_port_invalid() {
848 use crate::config_types::ReadyPort;
849
850 let mut ctx = make_context_with_daemon("redis", vec![6379]);
851 let mut config = PitchforkTomlDaemon {
852 run: "echo".to_string(),
853 ready_port: Some(ReadyPort::from_template("{{ name }}")),
854 ..Default::default()
855 };
856
857 let err = render_daemon_templates(&mut config, &mut ctx, None).unwrap_err();
858 assert!(matches!(err, RenderError::InvalidPort { .. }));
859 }
860
861 #[test]
862 fn test_render_daemon_templates_ready_port_literal_untouched() {
863 use crate::config_types::ReadyPort;
864
865 let mut ctx = make_context_with_daemon("redis", vec![6379]);
866 let mut config = PitchforkTomlDaemon {
867 run: "echo".to_string(),
868 ready_port: Some(ReadyPort::new(8080)),
869 ..Default::default()
870 };
871
872 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
873 assert_eq!(config.ready_port, Some(ReadyPort::new(8080)));
874 }
875
876 #[test]
877 fn test_render_daemon_templates_health_port() {
878 use crate::config_types::HealthPort;
879 use std::time::Duration;
880
881 let mut ctx = make_context_with_daemon("redis", vec![6379]);
882 let mut config = PitchforkTomlDaemon {
883 run: "echo".to_string(),
884 health_port: Some(HealthPort {
885 port: None,
886 template: Some("{{ daemons.redis.port }}".to_string()),
887 interval: Some(Duration::from_secs(10)),
888 retries: Some(3),
889 timeout: Some(Duration::from_secs(5)),
890 }),
891 ..Default::default()
892 };
893
894 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
895 let hp = config.health_port.unwrap();
896 assert_eq!(hp.port, Some(6379));
897 assert!(hp.template.is_none());
898 assert_eq!(hp.interval, Some(Duration::from_secs(10)));
899 assert_eq!(hp.retries, Some(3));
900 assert_eq!(hp.timeout, Some(Duration::from_secs(5)));
901 }
902
903 #[test]
904 fn test_render_daemon_templates_health_port_invalid() {
905 use crate::config_types::HealthPort;
906
907 let mut ctx = make_context_with_daemon("redis", vec![6379]);
908 let mut config = PitchforkTomlDaemon {
909 run: "echo".to_string(),
910 health_port: Some(HealthPort::from_template("{{ name }}")),
911 ..Default::default()
912 };
913
914 let err = render_daemon_templates(&mut config, &mut ctx, None).unwrap_err();
915 assert!(matches!(err, RenderError::InvalidPort { .. }));
916 }
917
918 #[test]
919 fn test_render_daemon_templates_health_port_literal_untouched() {
920 use crate::config_types::HealthPort;
921
922 let mut ctx = make_context_with_daemon("redis", vec![6379]);
923 let mut config = PitchforkTomlDaemon {
924 run: "echo".to_string(),
925 health_port: Some(HealthPort::new(8080)),
926 ..Default::default()
927 };
928
929 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
930 assert_eq!(config.health_port, Some(HealthPort::new(8080)));
931 }
932
933 #[test]
934 fn test_render_daemon_templates_hook_error_does_not_fail() {
935 let mut ctx = make_context_with_daemon("redis", vec![6379]);
936 let mut config = PitchforkTomlDaemon {
937 run: "echo".to_string(),
938 hooks: Some(crate::config_types::PitchforkTomlHooks {
939 on_ready: Some("{{ nonexistent }}".to_string()),
940 on_fail: None,
941 on_retry: None,
942 on_stop: None,
943 on_exit: None,
944 on_output: None,
945 }),
946 ..Default::default()
947 };
948
949 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
951 let hooks = config.hooks.unwrap();
952 assert!(hooks.on_ready.is_none());
953 }
954
955 #[test]
956 fn test_render_daemon_templates_run_error_still_fails() {
957 let mut ctx = make_context_with_daemon("redis", vec![6379]);
958 let mut config = PitchforkTomlDaemon {
959 run: "{{ nonexistent }}".to_string(),
960 ..Default::default()
961 };
962
963 assert!(render_daemon_templates(&mut config, &mut ctx, None).is_err());
965 }
966}