1use crate::daemon_id::DaemonId;
11use crate::pitchfork_toml::PitchforkTomlDaemon;
12use crate::settings::settings;
13use indexmap::IndexMap;
14use std::collections::HashMap;
15use std::path::PathBuf;
16
17#[derive(Debug, Clone)]
23pub struct DaemonTemplateState {
24 pub ports: Vec<u16>,
25 pub id: String,
26 pub name: String,
27 pub namespace: String,
28 pub slug: Option<String>,
29 pub dir: PathBuf,
30}
31
32impl DaemonTemplateState {
33 fn port(&self) -> Option<u16> {
34 self.ports.first().copied()
35 }
36}
37
38pub struct TemplateContext {
44 self_state: DaemonTemplateState,
45 daemon_states: HashMap<String, DaemonTemplateState>,
46}
47
48impl TemplateContext {
49 pub fn new(
56 id: &DaemonId,
57 daemon_config: &PitchforkTomlDaemon,
58 resolved_daemons: &HashMap<DaemonId, Vec<u16>>,
59 daemon_configs: &IndexMap<DaemonId, PitchforkTomlDaemon>,
60 ) -> Self {
61 let global_slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
62 let dir = crate::ipc::batch::resolve_daemon_dir(
63 daemon_config.dir.as_deref(),
64 daemon_config.path.as_deref(),
65 );
66
67 let self_state = DaemonTemplateState {
68 ports: Vec::new(),
69 id: id.qualified(),
70 name: id.name().to_string(),
71 namespace: id.namespace().to_string(),
72 slug: crate::pitchfork_toml::PitchforkToml::find_slug_for_daemon_in_registry(
73 id,
74 &global_slugs,
75 ),
76 dir,
77 };
78
79 let mut daemon_states = HashMap::new();
80 for (dep_id, ports) in resolved_daemons {
81 if let Some(config) = daemon_configs.get(dep_id) {
82 let dep_dir = crate::ipc::batch::resolve_daemon_dir(
83 config.dir.as_deref(),
84 config.path.as_deref(),
85 );
86 let state = DaemonTemplateState {
87 ports: ports.clone(),
88 id: dep_id.qualified(),
89 name: dep_id.name().to_string(),
90 namespace: dep_id.namespace().to_string(),
91 slug: crate::pitchfork_toml::PitchforkToml::find_slug_for_daemon_in_registry(
92 dep_id,
93 &global_slugs,
94 ),
95 dir: dep_dir,
96 };
97
98 if dep_id.namespace() == id.namespace() {
100 daemon_states.insert(dep_id.name().to_string(), state.clone());
101 }
102
103 daemon_states.insert(qualified_key(dep_id), state);
105 }
106 }
107
108 Self {
109 self_state,
110 daemon_states,
111 }
112 }
113
114 pub fn to_tera_context(&self) -> tera::Context {
116 let mut ctx = tera::Context::new();
117
118 ctx.insert("name", &self.self_state.name);
120 ctx.insert("namespace", &self.self_state.namespace);
121 ctx.insert("id", &self.self_state.id);
122 ctx.insert("slug", &self.self_state.slug);
123 ctx.insert("dir", &self.self_state.dir.to_string_lossy().to_string());
124
125 let mut daemons_map = serde_json::Map::new();
127 for (name, state) in &self.daemon_states {
128 if daemons_map.contains_key(name) {
129 continue;
130 }
131 daemons_map.insert(name.clone(), daemon_state_to_json(state));
132 }
133 ctx.insert("daemons", &serde_json::Value::Object(daemons_map));
134
135 let s = settings();
137 ctx.insert(
138 "settings",
139 &serde_json::json!({
140 "proxy": {
141 "enable": s.proxy.enable,
142 "tld": s.proxy.tld,
143 "port": s.proxy.port,
144 "https": s.proxy.https,
145 }
146 }),
147 );
148
149 let proxy_url = build_proxy_url(self.self_state.slug.as_deref(), &s);
152 ctx.insert("proxy_url", &proxy_url);
153
154 ctx
155 }
156}
157
158fn daemon_state_to_json(state: &DaemonTemplateState) -> serde_json::Value {
159 serde_json::json!({
160 "port": state.port(),
161 "ports": state.ports,
162 "id": state.id,
163 "name": state.name,
164 "namespace": state.namespace,
165 "slug": state.slug,
166 "dir": state.dir.to_string_lossy(),
167 })
168}
169
170fn qualified_key(id: &DaemonId) -> String {
173 format!("{}.{}", id.namespace(), id.name())
174}
175
176fn build_proxy_url(slug: Option<&str>, s: &crate::settings::Settings) -> Option<String> {
178 let slug = slug?;
179 let scheme = if s.proxy.https { "https" } else { "http" };
180 let tld = &s.proxy.tld;
181 let standard_port = if s.proxy.https { 443u16 } else { 80u16 };
182 let effective_port = u16::try_from(s.proxy.port).ok().filter(|&p| p > 0)?;
183 let host = format!("{slug}.{tld}");
184 Some(if effective_port == standard_port {
185 format!("{scheme}://{host}")
186 } else {
187 format!("{scheme}://{host}:{effective_port}")
188 })
189}
190
191pub fn render_template(template: &str, context: &TemplateContext) -> Result<String, RenderError> {
200 TemplateRenderer::new(context).render(template)
201}
202
203pub fn render_daemon_templates(
210 config: &mut PitchforkTomlDaemon,
211 context: &TemplateContext,
212) -> Result<(), RenderError> {
213 let mut renderer = TemplateRenderer::new(context);
214
215 config.run = renderer.render(&config.run)?;
216
217 if let Some(ref env) = config.env {
218 let rendered: IndexMap<String, String> = env
219 .iter()
220 .map(|(k, v)| Ok((k.clone(), renderer.render(v)?)))
221 .collect::<Result<_, RenderError>>()?;
222 config.env = Some(rendered);
223 }
224
225 if let Some(ref hooks) = config.hooks {
226 let rendered = crate::config_types::PitchforkTomlHooks {
227 on_ready: hooks
228 .on_ready
229 .as_deref()
230 .and_then(|t| renderer.render(t).ok()),
231 on_fail: hooks
232 .on_fail
233 .as_deref()
234 .and_then(|t| renderer.render(t).ok()),
235 on_retry: hooks
236 .on_retry
237 .as_deref()
238 .and_then(|t| renderer.render(t).ok()),
239 on_stop: hooks
240 .on_stop
241 .as_deref()
242 .and_then(|t| renderer.render(t).ok()),
243 on_exit: hooks
244 .on_exit
245 .as_deref()
246 .and_then(|t| renderer.render(t).ok()),
247 on_output: hooks.on_output.as_ref().and_then(|hook| {
248 renderer
249 .render(&hook.run)
250 .ok()
251 .map(|run| crate::config_types::OnOutputHook {
252 run,
253 filter: hook.filter.clone(),
254 regex: hook.regex.clone(),
255 debounce: hook.debounce.clone(),
256 })
257 }),
258 };
259 config.hooks = Some(rendered);
260 }
261
262 if let Some(ref cmd) = config.ready_cmd {
263 config.ready_cmd = Some(renderer.render(cmd)?);
264 }
265
266 Ok(())
267}
268
269fn contains_template_syntax(template: &str) -> bool {
270 template.contains("{{") || template.contains("{%") || template.contains("{#")
271}
272
273struct TemplateRenderer {
274 tera: tera::Tera,
275 context: tera::Context,
276 next_template_id: usize,
277}
278
279impl TemplateRenderer {
280 fn new(context: &TemplateContext) -> Self {
281 let mut tera = tera::Tera::default();
282 tera.register_filter(
283 "default",
284 |value: &tera::Value,
285 kwargs: tera::Kwargs,
286 _: &tera::State|
287 -> tera::TeraResult<tera::Value> {
288 let default_val = kwargs.must_get::<tera::Value>("value")?;
289 let boolean = kwargs.get::<bool>("boolean")?.unwrap_or_default();
290 if value.is_undefined() || value.is_none() || (boolean && !value.is_truthy()) {
291 Ok(default_val)
292 } else {
293 Ok(value.clone())
294 }
295 },
296 );
297 Self {
298 tera,
299 context: context.to_tera_context(),
300 next_template_id: 0,
301 }
302 }
303
304 fn render(&mut self, template: &str) -> Result<String, RenderError> {
305 if !contains_template_syntax(template) {
306 return Ok(template.to_string());
307 }
308
309 let template_name = format!("config_{}", self.next_template_id);
310 self.next_template_id += 1;
311
312 self.tera
313 .add_raw_template(&template_name, template)
314 .map_err(|e| RenderError::TemplateSyntax {
315 template: template.to_string(),
316 source: e,
317 })?;
318
319 self.tera
320 .render(&template_name, &self.context)
321 .map_err(|e| RenderError::RenderFailed {
322 template: template.to_string(),
323 source: e,
324 })
325 }
326}
327
328#[derive(Debug, thiserror::Error)]
333pub enum RenderError {
334 #[error("template syntax error in {template:?}: {source}")]
335 TemplateSyntax {
336 template: String,
337 source: tera::Error,
338 },
339 #[error("template render failed for {template:?}: {source}")]
340 RenderFailed {
341 template: String,
342 source: tera::Error,
343 },
344}
345
346#[cfg(test)]
351mod tests {
352 use super::*;
353
354 fn make_daemon_config(run: &str) -> PitchforkTomlDaemon {
355 PitchforkTomlDaemon {
356 run: run.to_string(),
357 ..Default::default()
358 }
359 }
360
361 fn make_context_with_daemon(name: &str, ports: Vec<u16>) -> TemplateContext {
362 let id = DaemonId::new("myproj", name);
363 let config = make_daemon_config("echo");
364 let mut resolved = HashMap::new();
365 resolved.insert(id.clone(), ports);
366 let mut configs = IndexMap::new();
367 configs.insert(id.clone(), make_daemon_config("echo"));
368 TemplateContext::new(
369 &DaemonId::new("myproj", "self"),
370 &config,
371 &resolved,
372 &configs,
373 )
374 }
375
376 #[test]
377 fn test_no_template_passthrough() {
378 let ctx = make_context_with_daemon("redis", vec![6379]);
379 assert_eq!(render_template("hello world", &ctx).unwrap(), "hello world");
380 }
381
382 #[test]
383 fn test_self_variables() {
384 let id = DaemonId::new("myproj", "api");
385 let config = make_daemon_config("echo");
386 let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
387
388 assert_eq!(render_template("{{ name }}", &ctx).unwrap(), "api");
389 assert_eq!(render_template("{{ namespace }}", &ctx).unwrap(), "myproj");
390 assert_eq!(render_template("{{ id }}", &ctx).unwrap(), "myproj/api");
391 }
392
393 #[test]
394 fn test_daemon_port_reference() {
395 let ctx = make_context_with_daemon("redis", vec![6379]);
396 assert_eq!(
397 render_template("{{ daemons.redis.port }}", &ctx).unwrap(),
398 "6379"
399 );
400 }
401
402 #[test]
403 fn test_daemon_ports_array() {
404 let ctx = make_context_with_daemon("redis", vec![6379, 6380]);
405 assert_eq!(
406 render_template("{{ daemons.redis.ports[0] }}", &ctx).unwrap(),
407 "6379"
408 );
409 assert_eq!(
410 render_template("{{ daemons.redis.ports[1] }}", &ctx).unwrap(),
411 "6380"
412 );
413 }
414
415 #[test]
416 fn test_daemon_qualified_name() {
417 let ctx = make_context_with_daemon("redis", vec![6379]);
418 assert_eq!(
419 render_template("{{ daemons[\"myproj.redis\"].port }}", &ctx).unwrap(),
420 "6379"
421 );
422 }
423
424 #[test]
425 fn test_short_name_only_matches_current_namespace() {
426 let self_id = DaemonId::new("app", "api");
427 let self_config = make_daemon_config("echo");
428 let other_id = DaemonId::new("infra", "redis");
429
430 let mut resolved = HashMap::new();
431 resolved.insert(other_id.clone(), vec![6379]);
432
433 let mut configs = IndexMap::new();
434 configs.insert(other_id.clone(), make_daemon_config("echo"));
435
436 let ctx = TemplateContext::new(&self_id, &self_config, &resolved, &configs);
437
438 assert!(render_template("{{ daemons.redis.port }}", &ctx).is_err());
439 assert_eq!(
440 render_template("{{ daemons[\"infra.redis\"].port }}", &ctx).unwrap(),
441 "6379"
442 );
443 }
444
445 #[test]
446 fn test_settings_reference() {
447 let ctx = make_context_with_daemon("redis", vec![6379]);
448 let result = render_template("{{ settings.proxy.tld }}", &ctx).unwrap();
449 assert_eq!(result, "localhost");
451 }
452
453 #[test]
454 fn test_undefined_variable_error() {
455 let ctx = make_context_with_daemon("redis", vec![6379]);
456 let result = render_template("{{ nonexistent }}", &ctx);
457 assert!(result.is_err());
458 }
459
460 #[test]
461 fn test_comment_only_template_is_parsed() {
462 let ctx = make_context_with_daemon("redis", vec![6379]);
463 assert_eq!(
464 render_template("before{# hidden #}after", &ctx).unwrap(),
465 "beforeafter"
466 );
467 }
468
469 #[test]
470 fn test_proxy_url_is_present_as_null_when_slug_is_missing() {
471 let id = DaemonId::new("myproj", "api");
472 let config = make_daemon_config("echo");
473 let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
474
475 assert_eq!(
476 render_template("{{ proxy_url | default(value=\"none\") }}", &ctx).unwrap(),
477 "none"
478 );
479 }
480
481 #[test]
482 fn test_mixed_template_and_literal() {
483 let ctx = make_context_with_daemon("redis", vec![6379]);
484 assert_eq!(
485 render_template("redis://localhost:{{ daemons.redis.port }}/0", &ctx).unwrap(),
486 "redis://localhost:6379/0"
487 );
488 }
489
490 #[test]
491 fn test_render_daemon_templates_run() {
492 let ctx = make_context_with_daemon("redis", vec![6379]);
493 let mut config = PitchforkTomlDaemon {
494 run: "redis-cli -p {{ daemons.redis.port }}".to_string(),
495 ..Default::default()
496 };
497 render_daemon_templates(&mut config, &ctx).unwrap();
498 assert_eq!(config.run, "redis-cli -p 6379");
499 }
500
501 #[test]
502 fn test_render_daemon_templates_env() {
503 let ctx = make_context_with_daemon("redis", vec![6379]);
504 let mut config = PitchforkTomlDaemon {
505 run: "echo".to_string(),
506 env: Some(IndexMap::from([
507 (
508 "DATABASE_URL".to_string(),
509 "redis://localhost:{{ daemons.redis.port }}/0".to_string(),
510 ),
511 ("STATIC_VAR".to_string(), "unchanged".to_string()),
512 ])),
513 ..Default::default()
514 };
515 render_daemon_templates(&mut config, &ctx).unwrap();
516 let env = config.env.unwrap();
517 assert_eq!(env["DATABASE_URL"], "redis://localhost:6379/0");
518 assert_eq!(env["STATIC_VAR"], "unchanged");
519 }
520
521 #[test]
522 fn test_render_daemon_templates_on_output_run() {
523 let ctx = make_context_with_daemon("redis", vec![6379]);
524 let mut config = PitchforkTomlDaemon {
525 run: "echo".to_string(),
526 hooks: Some(crate::config_types::PitchforkTomlHooks {
527 on_ready: None,
528 on_fail: None,
529 on_retry: None,
530 on_stop: None,
531 on_exit: None,
532 on_output: Some(crate::config_types::OnOutputHook {
533 run: "curl http://localhost:{{ daemons.redis.port }}".to_string(),
534 filter: Some("ready".to_string()),
535 regex: None,
536 debounce: None,
537 }),
538 }),
539 ..Default::default()
540 };
541
542 render_daemon_templates(&mut config, &ctx).unwrap();
543
544 let hooks = config.hooks.unwrap();
545 let on_output = hooks.on_output.unwrap();
546 assert_eq!(on_output.run, "curl http://localhost:6379");
547 assert_eq!(on_output.filter.as_deref(), Some("ready"));
548 }
549
550 #[test]
551 fn test_render_daemon_templates_hook_error_does_not_fail() {
552 let ctx = make_context_with_daemon("redis", vec![6379]);
553 let mut config = PitchforkTomlDaemon {
554 run: "echo".to_string(),
555 hooks: Some(crate::config_types::PitchforkTomlHooks {
556 on_ready: Some("{{ nonexistent }}".to_string()),
557 on_fail: None,
558 on_retry: None,
559 on_stop: None,
560 on_exit: None,
561 on_output: None,
562 }),
563 ..Default::default()
564 };
565
566 render_daemon_templates(&mut config, &ctx).unwrap();
568 let hooks = config.hooks.unwrap();
569 assert!(hooks.on_ready.is_none());
570 }
571
572 #[test]
573 fn test_render_daemon_templates_run_error_still_fails() {
574 let ctx = make_context_with_daemon("redis", vec![6379]);
575 let mut config = PitchforkTomlDaemon {
576 run: "{{ nonexistent }}".to_string(),
577 ..Default::default()
578 };
579
580 assert!(render_daemon_templates(&mut config, &ctx).is_err());
582 }
583}