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}
48
49impl TemplateContext {
50 pub fn new(
57 id: &DaemonId,
58 daemon_config: &PitchforkTomlDaemon,
59 resolved_daemons: &HashMap<DaemonId, Vec<u16>>,
60 daemon_configs: &IndexMap<DaemonId, PitchforkTomlDaemon>,
61 ) -> Self {
62 let global_slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
63 let dir = crate::ipc::batch::resolve_daemon_dir(
64 daemon_config.dir.as_deref(),
65 daemon_config.path.as_deref(),
66 );
67
68 let self_state = DaemonTemplateState {
69 ports: Vec::new(),
70 id: id.qualified(),
71 name: id.name().to_string(),
72 namespace: id.namespace().to_string(),
73 slug: crate::pitchfork_toml::PitchforkToml::find_slug_for_daemon_in_registry(
74 id,
75 &global_slugs,
76 ),
77 dir,
78 };
79
80 let mut daemon_states = HashMap::new();
81 for (dep_id, ports) in resolved_daemons {
82 if let Some(config) = daemon_configs.get(dep_id) {
83 let dep_dir = crate::ipc::batch::resolve_daemon_dir(
84 config.dir.as_deref(),
85 config.path.as_deref(),
86 );
87 let state = DaemonTemplateState {
88 ports: ports.clone(),
89 id: dep_id.qualified(),
90 name: dep_id.name().to_string(),
91 namespace: dep_id.namespace().to_string(),
92 slug: crate::pitchfork_toml::PitchforkToml::find_slug_for_daemon_in_registry(
93 dep_id,
94 &global_slugs,
95 ),
96 dir: dep_dir,
97 };
98
99 if dep_id.namespace() == id.namespace() {
101 daemon_states.insert(dep_id.name().to_string(), state.clone());
102 }
103
104 daemon_states.insert(qualified_key(dep_id), state);
106 }
107 }
108
109 Self {
110 self_state,
111 daemon_states,
112 }
113 }
114
115 pub fn to_tera_context(&self) -> tera::Context {
117 let mut ctx = tera::Context::new();
118
119 ctx.insert("name", &self.self_state.name);
121 ctx.insert("namespace", &self.self_state.namespace);
122 ctx.insert("id", &self.self_state.id);
123 ctx.insert("slug", &self.self_state.slug);
124 ctx.insert("dir", &self.self_state.dir.to_string_lossy().to_string());
125
126 let mut daemons_map = serde_json::Map::new();
128 for (name, state) in &self.daemon_states {
129 if daemons_map.contains_key(name) {
130 continue;
131 }
132 daemons_map.insert(name.clone(), daemon_state_to_json(state));
133 }
134 ctx.insert("daemons", &serde_json::Value::Object(daemons_map));
135
136 let s = settings();
138 ctx.insert(
139 "settings",
140 &serde_json::json!({
141 "proxy": {
142 "enable": s.proxy.enable,
143 "tld": s.proxy.tld,
144 "port": s.proxy.port,
145 "https": s.proxy.https,
146 }
147 }),
148 );
149
150 let proxy_url = build_proxy_url(self.self_state.slug.as_deref(), &s);
153 ctx.insert("proxy_url", &proxy_url);
154
155 ctx
156 }
157}
158
159fn daemon_state_to_json(state: &DaemonTemplateState) -> serde_json::Value {
160 serde_json::json!({
161 "port": state.port(),
162 "ports": state.ports,
163 "id": state.id,
164 "name": state.name,
165 "namespace": state.namespace,
166 "slug": state.slug,
167 "dir": state.dir.to_string_lossy(),
168 })
169}
170
171fn qualified_key(id: &DaemonId) -> String {
174 format!("{}.{}", id.namespace(), id.name())
175}
176
177fn build_proxy_url(slug: Option<&str>, s: &crate::settings::Settings) -> Option<String> {
179 let slug = slug?;
180 let scheme = if s.proxy.https { "https" } else { "http" };
181 let tld = &s.proxy.tld;
182 let standard_port = if s.proxy.https { 443u16 } else { 80u16 };
183 let effective_port = u16::try_from(s.proxy.port).ok().filter(|&p| p > 0)?;
184 let host = format!("{slug}.{tld}");
185 Some(if effective_port == standard_port {
186 format!("{scheme}://{host}")
187 } else {
188 format!("{scheme}://{host}:{effective_port}")
189 })
190}
191
192pub fn render_template(template: &str, context: &TemplateContext) -> Result<String, RenderError> {
201 TemplateRenderer::new(context).render(template)
202}
203
204pub fn render_daemon_templates(
211 config: &mut PitchforkTomlDaemon,
212 context: &TemplateContext,
213) -> Result<(), RenderError> {
214 let mut renderer = TemplateRenderer::new(context);
215
216 config.run = renderer.render(&config.run)?;
217
218 if let Some(ref env) = config.env {
219 let rendered: IndexMap<String, String> = env
220 .iter()
221 .map(|(k, v)| Ok((k.clone(), renderer.render(v)?)))
222 .collect::<Result<_, RenderError>>()?;
223 config.env = Some(rendered);
224 }
225
226 if let Some(ref hooks) = config.hooks {
227 let rendered = crate::config_types::PitchforkTomlHooks {
228 on_ready: hooks
229 .on_ready
230 .as_deref()
231 .and_then(|t| renderer.render(t).ok()),
232 on_fail: hooks
233 .on_fail
234 .as_deref()
235 .and_then(|t| renderer.render(t).ok()),
236 on_retry: hooks
237 .on_retry
238 .as_deref()
239 .and_then(|t| renderer.render(t).ok()),
240 on_stop: hooks
241 .on_stop
242 .as_deref()
243 .and_then(|t| renderer.render(t).ok()),
244 on_exit: hooks
245 .on_exit
246 .as_deref()
247 .and_then(|t| renderer.render(t).ok()),
248 on_output: hooks.on_output.as_ref().and_then(|hook| {
249 renderer
250 .render(&hook.run)
251 .ok()
252 .map(|run| crate::config_types::OnOutputHook {
253 run,
254 filter: hook.filter.clone(),
255 regex: hook.regex.clone(),
256 debounce: hook.debounce.clone(),
257 })
258 }),
259 };
260 config.hooks = Some(rendered);
261 }
262
263 if let Some(ref cmd) = config.ready_cmd {
264 config.ready_cmd = Some(crate::pitchfork_toml::ReadyCmd {
265 run: renderer.render(&cmd.run)?,
266 timeout: cmd.timeout,
267 });
268 }
269
270 if let Some(ref output) = config.ready_output {
271 let pattern = renderer.render(&output.pattern)?;
272 config.ready_output = Some(crate::config_types::ReadyOutput {
273 pattern,
274 timeout: output.timeout,
275 });
276 }
277
278 if let Some(ref http) = config.ready_http {
279 let mut http = http.clone();
280 http.url = renderer.render(&http.url)?;
281 config.ready_http = Some(http);
282 }
283
284 if let Some(ref ready_port) = config.ready_port {
285 if let Some(ref template) = ready_port.template {
286 let rendered = renderer.render(template)?;
287 let port = rendered
288 .trim()
289 .parse::<u16>()
290 .ok()
291 .filter(|&p| p > 0)
292 .ok_or_else(|| RenderError::InvalidPort {
293 template: template.clone(),
294 rendered: rendered.clone(),
295 })?;
296 config.ready_port = Some(crate::config_types::ReadyPort {
297 port: Some(port),
298 template: None,
299 timeout: ready_port.timeout,
300 });
301 }
302 }
303
304 Ok(())
305}
306
307fn contains_template_syntax(template: &str) -> bool {
308 template.contains("{{") || template.contains("{%") || template.contains("{#")
309}
310
311struct TemplateRenderer {
312 tera: tera::Tera,
313 context: tera::Context,
314 next_template_id: usize,
315}
316
317impl TemplateRenderer {
318 fn new(context: &TemplateContext) -> Self {
319 let mut tera = tera::Tera::default();
320 tera.register_filter(
321 "default",
322 |value: &tera::Value,
323 kwargs: tera::Kwargs,
324 _: &tera::State|
325 -> tera::TeraResult<tera::Value> {
326 let default_val = kwargs.must_get::<tera::Value>("value")?;
327 let boolean = kwargs.get::<bool>("boolean")?.unwrap_or_default();
328 if value.is_undefined() || value.is_none() || (boolean && !value.is_truthy()) {
329 Ok(default_val)
330 } else {
331 Ok(value.clone())
332 }
333 },
334 );
335 Self {
336 tera,
337 context: context.to_tera_context(),
338 next_template_id: 0,
339 }
340 }
341
342 fn render(&mut self, template: &str) -> Result<String, RenderError> {
343 if !contains_template_syntax(template) {
344 return Ok(template.to_string());
345 }
346
347 let template_name = format!("config_{}", self.next_template_id);
348 self.next_template_id += 1;
349
350 self.tera
351 .add_raw_template(&template_name, template)
352 .map_err(|e| RenderError::TemplateSyntax {
353 template: template.to_string(),
354 source: e,
355 })?;
356
357 self.tera
358 .render(&template_name, &self.context)
359 .map_err(|e| RenderError::RenderFailed {
360 template: template.to_string(),
361 source: e,
362 })
363 }
364}
365
366#[derive(Debug, thiserror::Error)]
371pub enum RenderError {
372 #[error("template syntax error in {template:?}: {source}")]
373 TemplateSyntax {
374 template: String,
375 source: tera::Error,
376 },
377 #[error("template render failed for {template:?}: {source}")]
378 RenderFailed {
379 template: String,
380 source: tera::Error,
381 },
382 #[error(
383 "ready_port template {template:?} rendered to {rendered:?}, expected a port number (1-65535)"
384 )]
385 InvalidPort { template: String, rendered: String },
386}
387
388#[cfg(test)]
393mod tests {
394 use super::*;
395
396 fn make_daemon_config(run: &str) -> PitchforkTomlDaemon {
397 PitchforkTomlDaemon {
398 run: run.to_string(),
399 ..Default::default()
400 }
401 }
402
403 fn make_context_with_daemon(name: &str, ports: Vec<u16>) -> TemplateContext {
404 let id = DaemonId::new("myproj", name);
405 let config = make_daemon_config("echo");
406 let mut resolved = HashMap::new();
407 resolved.insert(id.clone(), ports);
408 let mut configs = IndexMap::new();
409 configs.insert(id.clone(), make_daemon_config("echo"));
410 TemplateContext::new(
411 &DaemonId::new("myproj", "self"),
412 &config,
413 &resolved,
414 &configs,
415 )
416 }
417
418 #[test]
419 fn test_no_template_passthrough() {
420 let ctx = make_context_with_daemon("redis", vec![6379]);
421 assert_eq!(render_template("hello world", &ctx).unwrap(), "hello world");
422 }
423
424 #[test]
425 fn test_self_variables() {
426 let id = DaemonId::new("myproj", "api");
427 let config = make_daemon_config("echo");
428 let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
429
430 assert_eq!(render_template("{{ name }}", &ctx).unwrap(), "api");
431 assert_eq!(render_template("{{ namespace }}", &ctx).unwrap(), "myproj");
432 assert_eq!(render_template("{{ id }}", &ctx).unwrap(), "myproj/api");
433 }
434
435 #[test]
436 fn test_daemon_port_reference() {
437 let ctx = make_context_with_daemon("redis", vec![6379]);
438 assert_eq!(
439 render_template("{{ daemons.redis.port }}", &ctx).unwrap(),
440 "6379"
441 );
442 }
443
444 #[test]
445 fn test_daemon_ports_array() {
446 let ctx = make_context_with_daemon("redis", vec![6379, 6380]);
447 assert_eq!(
448 render_template("{{ daemons.redis.ports[0] }}", &ctx).unwrap(),
449 "6379"
450 );
451 assert_eq!(
452 render_template("{{ daemons.redis.ports[1] }}", &ctx).unwrap(),
453 "6380"
454 );
455 }
456
457 #[test]
458 fn test_daemon_qualified_name() {
459 let ctx = make_context_with_daemon("redis", vec![6379]);
460 assert_eq!(
461 render_template("{{ daemons[\"myproj.redis\"].port }}", &ctx).unwrap(),
462 "6379"
463 );
464 }
465
466 #[test]
467 fn test_short_name_only_matches_current_namespace() {
468 let self_id = DaemonId::new("app", "api");
469 let self_config = make_daemon_config("echo");
470 let other_id = DaemonId::new("infra", "redis");
471
472 let mut resolved = HashMap::new();
473 resolved.insert(other_id.clone(), vec![6379]);
474
475 let mut configs = IndexMap::new();
476 configs.insert(other_id.clone(), make_daemon_config("echo"));
477
478 let ctx = TemplateContext::new(&self_id, &self_config, &resolved, &configs);
479
480 assert!(render_template("{{ daemons.redis.port }}", &ctx).is_err());
481 assert_eq!(
482 render_template("{{ daemons[\"infra.redis\"].port }}", &ctx).unwrap(),
483 "6379"
484 );
485 }
486
487 #[test]
488 fn test_settings_reference() {
489 let ctx = make_context_with_daemon("redis", vec![6379]);
490 let result = render_template("{{ settings.proxy.tld }}", &ctx).unwrap();
491 assert_eq!(result, "localhost");
493 }
494
495 #[test]
496 fn test_undefined_variable_error() {
497 let ctx = make_context_with_daemon("redis", vec![6379]);
498 let result = render_template("{{ nonexistent }}", &ctx);
499 assert!(result.is_err());
500 }
501
502 #[test]
503 fn test_comment_only_template_is_parsed() {
504 let ctx = make_context_with_daemon("redis", vec![6379]);
505 assert_eq!(
506 render_template("before{# hidden #}after", &ctx).unwrap(),
507 "beforeafter"
508 );
509 }
510
511 #[test]
512 fn test_proxy_url_is_present_as_null_when_slug_is_missing() {
513 let id = DaemonId::new("myproj", "api");
514 let config = make_daemon_config("echo");
515 let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
516
517 assert_eq!(
518 render_template("{{ proxy_url | default(value=\"none\") }}", &ctx).unwrap(),
519 "none"
520 );
521 }
522
523 #[test]
524 fn test_mixed_template_and_literal() {
525 let ctx = make_context_with_daemon("redis", vec![6379]);
526 assert_eq!(
527 render_template("redis://localhost:{{ daemons.redis.port }}/0", &ctx).unwrap(),
528 "redis://localhost:6379/0"
529 );
530 }
531
532 #[test]
533 fn test_render_daemon_templates_run() {
534 let ctx = make_context_with_daemon("redis", vec![6379]);
535 let mut config = PitchforkTomlDaemon {
536 run: "redis-cli -p {{ daemons.redis.port }}".to_string(),
537 ..Default::default()
538 };
539 render_daemon_templates(&mut config, &ctx).unwrap();
540 assert_eq!(config.run, "redis-cli -p 6379");
541 }
542
543 #[test]
544 fn test_render_daemon_templates_env() {
545 let ctx = make_context_with_daemon("redis", vec![6379]);
546 let mut config = PitchforkTomlDaemon {
547 run: "echo".to_string(),
548 env: Some(IndexMap::from([
549 (
550 "DATABASE_URL".to_string(),
551 "redis://localhost:{{ daemons.redis.port }}/0".to_string(),
552 ),
553 ("STATIC_VAR".to_string(), "unchanged".to_string()),
554 ])),
555 ..Default::default()
556 };
557 render_daemon_templates(&mut config, &ctx).unwrap();
558 let env = config.env.unwrap();
559 assert_eq!(env["DATABASE_URL"], "redis://localhost:6379/0");
560 assert_eq!(env["STATIC_VAR"], "unchanged");
561 }
562
563 #[test]
564 fn test_render_daemon_templates_on_output_run() {
565 let ctx = make_context_with_daemon("redis", vec![6379]);
566 let mut config = PitchforkTomlDaemon {
567 run: "echo".to_string(),
568 hooks: Some(crate::config_types::PitchforkTomlHooks {
569 on_ready: None,
570 on_fail: None,
571 on_retry: None,
572 on_stop: None,
573 on_exit: None,
574 on_output: Some(crate::config_types::OnOutputHook {
575 run: "curl http://localhost:{{ daemons.redis.port }}".to_string(),
576 filter: Some("ready".to_string()),
577 regex: None,
578 debounce: None,
579 }),
580 }),
581 ..Default::default()
582 };
583
584 render_daemon_templates(&mut config, &ctx).unwrap();
585
586 let hooks = config.hooks.unwrap();
587 let on_output = hooks.on_output.unwrap();
588 assert_eq!(on_output.run, "curl http://localhost:6379");
589 assert_eq!(on_output.filter.as_deref(), Some("ready"));
590 }
591
592 #[test]
593 fn test_render_daemon_templates_ready_fields() {
594 use crate::config_types::{ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort};
595
596 let ctx = make_context_with_daemon("redis", vec![6379]);
597 let mut config = PitchforkTomlDaemon {
598 run: "echo".to_string(),
599 ready_cmd: Some(ReadyCmd::new("redis-cli -p {{ daemons.redis.port }} ping")),
600 ready_output: Some(ReadyOutput::new("listening on {{ daemons.redis.port }}")),
601 ready_http: Some(ReadyHttp {
602 url: "http://localhost:{{ daemons.redis.port }}/health".to_string(),
603 status: vec![200, 401],
604 timeout: None,
605 }),
606 ready_port: Some(ReadyPort::from_template("{{ daemons.redis.port }}")),
607 ..Default::default()
608 };
609
610 render_daemon_templates(&mut config, &ctx).unwrap();
611
612 assert_eq!(
613 config.ready_cmd.as_ref().unwrap().run,
614 "redis-cli -p 6379 ping"
615 );
616 assert_eq!(
617 config.ready_output.as_ref().unwrap().pattern,
618 "listening on 6379"
619 );
620 let http = config.ready_http.unwrap();
621 assert_eq!(http.url, "http://localhost:6379/health");
622 assert_eq!(http.status, vec![200, 401]);
623 assert_eq!(config.ready_port, Some(ReadyPort::new(6379)));
624 }
625
626 #[test]
627 fn test_render_daemon_templates_ready_port_invalid() {
628 use crate::config_types::ReadyPort;
629
630 let ctx = make_context_with_daemon("redis", vec![6379]);
631 let mut config = PitchforkTomlDaemon {
632 run: "echo".to_string(),
633 ready_port: Some(ReadyPort::from_template("{{ name }}")),
634 ..Default::default()
635 };
636
637 let err = render_daemon_templates(&mut config, &ctx).unwrap_err();
638 assert!(matches!(err, RenderError::InvalidPort { .. }));
639 }
640
641 #[test]
642 fn test_render_daemon_templates_ready_port_literal_untouched() {
643 use crate::config_types::ReadyPort;
644
645 let ctx = make_context_with_daemon("redis", vec![6379]);
646 let mut config = PitchforkTomlDaemon {
647 run: "echo".to_string(),
648 ready_port: Some(ReadyPort::new(8080)),
649 ..Default::default()
650 };
651
652 render_daemon_templates(&mut config, &ctx).unwrap();
653 assert_eq!(config.ready_port, Some(ReadyPort::new(8080)));
654 }
655
656 #[test]
657 fn test_render_daemon_templates_hook_error_does_not_fail() {
658 let ctx = make_context_with_daemon("redis", vec![6379]);
659 let mut config = PitchforkTomlDaemon {
660 run: "echo".to_string(),
661 hooks: Some(crate::config_types::PitchforkTomlHooks {
662 on_ready: Some("{{ nonexistent }}".to_string()),
663 on_fail: None,
664 on_retry: None,
665 on_stop: None,
666 on_exit: None,
667 on_output: None,
668 }),
669 ..Default::default()
670 };
671
672 render_daemon_templates(&mut config, &ctx).unwrap();
674 let hooks = config.hooks.unwrap();
675 assert!(hooks.on_ready.is_none());
676 }
677
678 #[test]
679 fn test_render_daemon_templates_run_error_still_fails() {
680 let ctx = make_context_with_daemon("redis", vec![6379]);
681 let mut config = PitchforkTomlDaemon {
682 run: "{{ nonexistent }}".to_string(),
683 ..Default::default()
684 };
685
686 assert!(render_daemon_templates(&mut config, &ctx).is_err());
688 }
689}