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 Self {
282 tera: tera::Tera::default(),
283 context: context.to_tera_context(),
284 next_template_id: 0,
285 }
286 }
287
288 fn render(&mut self, template: &str) -> Result<String, RenderError> {
289 if !contains_template_syntax(template) {
290 return Ok(template.to_string());
291 }
292
293 let template_name = format!("config_{}", self.next_template_id);
294 self.next_template_id += 1;
295
296 self.tera
297 .add_raw_template(&template_name, template)
298 .map_err(|e| RenderError::TemplateSyntax {
299 template: template.to_string(),
300 source: e,
301 })?;
302
303 self.tera
304 .render(&template_name, &self.context)
305 .map_err(|e| RenderError::RenderFailed {
306 template: template.to_string(),
307 source: e,
308 })
309 }
310}
311
312#[derive(Debug, thiserror::Error)]
317pub enum RenderError {
318 #[error("template syntax error in {template:?}: {source}")]
319 TemplateSyntax {
320 template: String,
321 source: tera::Error,
322 },
323 #[error("template render failed for {template:?}: {source}")]
324 RenderFailed {
325 template: String,
326 source: tera::Error,
327 },
328}
329
330#[cfg(test)]
335mod tests {
336 use super::*;
337
338 fn make_daemon_config(run: &str) -> PitchforkTomlDaemon {
339 PitchforkTomlDaemon {
340 run: run.to_string(),
341 ..Default::default()
342 }
343 }
344
345 fn make_context_with_daemon(name: &str, ports: Vec<u16>) -> TemplateContext {
346 let id = DaemonId::new("myproj", name);
347 let config = make_daemon_config("echo");
348 let mut resolved = HashMap::new();
349 resolved.insert(id.clone(), ports);
350 let mut configs = IndexMap::new();
351 configs.insert(id.clone(), make_daemon_config("echo"));
352 TemplateContext::new(
353 &DaemonId::new("myproj", "self"),
354 &config,
355 &resolved,
356 &configs,
357 )
358 }
359
360 #[test]
361 fn test_no_template_passthrough() {
362 let ctx = make_context_with_daemon("redis", vec![6379]);
363 assert_eq!(render_template("hello world", &ctx).unwrap(), "hello world");
364 }
365
366 #[test]
367 fn test_self_variables() {
368 let id = DaemonId::new("myproj", "api");
369 let config = make_daemon_config("echo");
370 let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
371
372 assert_eq!(render_template("{{ name }}", &ctx).unwrap(), "api");
373 assert_eq!(render_template("{{ namespace }}", &ctx).unwrap(), "myproj");
374 assert_eq!(render_template("{{ id }}", &ctx).unwrap(), "myproj/api");
375 }
376
377 #[test]
378 fn test_daemon_port_reference() {
379 let ctx = make_context_with_daemon("redis", vec![6379]);
380 assert_eq!(
381 render_template("{{ daemons.redis.port }}", &ctx).unwrap(),
382 "6379"
383 );
384 }
385
386 #[test]
387 fn test_daemon_ports_array() {
388 let ctx = make_context_with_daemon("redis", vec![6379, 6380]);
389 assert_eq!(
390 render_template("{{ daemons.redis.ports[0] }}", &ctx).unwrap(),
391 "6379"
392 );
393 assert_eq!(
394 render_template("{{ daemons.redis.ports[1] }}", &ctx).unwrap(),
395 "6380"
396 );
397 }
398
399 #[test]
400 fn test_daemon_qualified_name() {
401 let ctx = make_context_with_daemon("redis", vec![6379]);
402 assert_eq!(
403 render_template("{{ daemons[\"myproj.redis\"].port }}", &ctx).unwrap(),
404 "6379"
405 );
406 }
407
408 #[test]
409 fn test_short_name_only_matches_current_namespace() {
410 let self_id = DaemonId::new("app", "api");
411 let self_config = make_daemon_config("echo");
412 let other_id = DaemonId::new("infra", "redis");
413
414 let mut resolved = HashMap::new();
415 resolved.insert(other_id.clone(), vec![6379]);
416
417 let mut configs = IndexMap::new();
418 configs.insert(other_id.clone(), make_daemon_config("echo"));
419
420 let ctx = TemplateContext::new(&self_id, &self_config, &resolved, &configs);
421
422 assert!(render_template("{{ daemons.redis.port }}", &ctx).is_err());
423 assert_eq!(
424 render_template("{{ daemons[\"infra.redis\"].port }}", &ctx).unwrap(),
425 "6379"
426 );
427 }
428
429 #[test]
430 fn test_settings_reference() {
431 let ctx = make_context_with_daemon("redis", vec![6379]);
432 let result = render_template("{{ settings.proxy.tld }}", &ctx).unwrap();
433 assert_eq!(result, "localhost");
435 }
436
437 #[test]
438 fn test_undefined_variable_error() {
439 let ctx = make_context_with_daemon("redis", vec![6379]);
440 let result = render_template("{{ nonexistent }}", &ctx);
441 assert!(result.is_err());
442 }
443
444 #[test]
445 fn test_comment_only_template_is_parsed() {
446 let ctx = make_context_with_daemon("redis", vec![6379]);
447 assert_eq!(
448 render_template("before{# hidden #}after", &ctx).unwrap(),
449 "beforeafter"
450 );
451 }
452
453 #[test]
454 fn test_proxy_url_is_present_as_null_when_slug_is_missing() {
455 let id = DaemonId::new("myproj", "api");
456 let config = make_daemon_config("echo");
457 let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
458
459 assert_eq!(
460 render_template("{{ proxy_url | default(value=\"none\") }}", &ctx).unwrap(),
461 "none"
462 );
463 }
464
465 #[test]
466 fn test_mixed_template_and_literal() {
467 let ctx = make_context_with_daemon("redis", vec![6379]);
468 assert_eq!(
469 render_template("redis://localhost:{{ daemons.redis.port }}/0", &ctx).unwrap(),
470 "redis://localhost:6379/0"
471 );
472 }
473
474 #[test]
475 fn test_render_daemon_templates_run() {
476 let ctx = make_context_with_daemon("redis", vec![6379]);
477 let mut config = PitchforkTomlDaemon {
478 run: "redis-cli -p {{ daemons.redis.port }}".to_string(),
479 ..Default::default()
480 };
481 render_daemon_templates(&mut config, &ctx).unwrap();
482 assert_eq!(config.run, "redis-cli -p 6379");
483 }
484
485 #[test]
486 fn test_render_daemon_templates_env() {
487 let ctx = make_context_with_daemon("redis", vec![6379]);
488 let mut config = PitchforkTomlDaemon {
489 run: "echo".to_string(),
490 env: Some(IndexMap::from([
491 (
492 "DATABASE_URL".to_string(),
493 "redis://localhost:{{ daemons.redis.port }}/0".to_string(),
494 ),
495 ("STATIC_VAR".to_string(), "unchanged".to_string()),
496 ])),
497 ..Default::default()
498 };
499 render_daemon_templates(&mut config, &ctx).unwrap();
500 let env = config.env.unwrap();
501 assert_eq!(env["DATABASE_URL"], "redis://localhost:6379/0");
502 assert_eq!(env["STATIC_VAR"], "unchanged");
503 }
504
505 #[test]
506 fn test_render_daemon_templates_on_output_run() {
507 let ctx = make_context_with_daemon("redis", vec![6379]);
508 let mut config = PitchforkTomlDaemon {
509 run: "echo".to_string(),
510 hooks: Some(crate::config_types::PitchforkTomlHooks {
511 on_ready: None,
512 on_fail: None,
513 on_retry: None,
514 on_stop: None,
515 on_exit: None,
516 on_output: Some(crate::config_types::OnOutputHook {
517 run: "curl http://localhost:{{ daemons.redis.port }}".to_string(),
518 filter: Some("ready".to_string()),
519 regex: None,
520 debounce: None,
521 }),
522 }),
523 ..Default::default()
524 };
525
526 render_daemon_templates(&mut config, &ctx).unwrap();
527
528 let hooks = config.hooks.unwrap();
529 let on_output = hooks.on_output.unwrap();
530 assert_eq!(on_output.run, "curl http://localhost:6379");
531 assert_eq!(on_output.filter.as_deref(), Some("ready"));
532 }
533
534 #[test]
535 fn test_render_daemon_templates_hook_error_does_not_fail() {
536 let ctx = make_context_with_daemon("redis", vec![6379]);
537 let mut config = PitchforkTomlDaemon {
538 run: "echo".to_string(),
539 hooks: Some(crate::config_types::PitchforkTomlHooks {
540 on_ready: Some("{{ nonexistent }}".to_string()),
541 on_fail: None,
542 on_retry: None,
543 on_stop: None,
544 on_exit: None,
545 on_output: None,
546 }),
547 ..Default::default()
548 };
549
550 render_daemon_templates(&mut config, &ctx).unwrap();
552 let hooks = config.hooks.unwrap();
553 assert!(hooks.on_ready.is_none());
554 }
555
556 #[test]
557 fn test_render_daemon_templates_run_error_still_fails() {
558 let ctx = make_context_with_daemon("redis", vec![6379]);
559 let mut config = PitchforkTomlDaemon {
560 run: "{{ nonexistent }}".to_string(),
561 ..Default::default()
562 };
563
564 assert!(render_daemon_templates(&mut config, &ctx).is_err());
566 }
567}