use std::sync::Arc;
use crate::domain::error::WireResult;
use crate::domain::port::{ProjectionInput, ProjectionRenderer};
use crate::infrastructure::template::{HandlebarsEngine, TemplateEngine};
use async_trait::async_trait;
pub struct StaticProjection {
engine: Arc<dyn TemplateEngine>,
}
impl StaticProjection {
pub fn with_engine(engine: Arc<dyn TemplateEngine>) -> Self {
Self { engine }
}
pub fn new() -> Self {
Self {
engine: Arc::new(HandlebarsEngine::new()),
}
}
}
impl Default for StaticProjection {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl ProjectionRenderer for StaticProjection {
fn kind(&self) -> &'static str {
"static"
}
async fn render(&self, input: ProjectionInput<'_>) -> WireResult<String> {
self.engine.render(input.template, input.spec_result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::entity::TargetForm;
use serde_json::json;
#[tokio::test]
async fn static_projection_kind_is_static() {
assert_eq!(StaticProjection::new().kind(), "static");
}
#[tokio::test]
async fn static_projection_delegates_to_template_engine() {
let proj = StaticProjection::new();
let data = json!({"name": "alpha"});
let cfg = json!({});
let input = ProjectionInput {
spec_result: &data,
template: "Hi, {{name}}!",
target_form: TargetForm::Prompt,
persona_id: None,
config: &cfg,
};
let out = proj.render(input).await.unwrap();
assert_eq!(out, "Hi, alpha!");
}
#[tokio::test]
async fn static_projection_engine_error_surfaces_as_render_error_marker() {
let proj = StaticProjection::new();
let data = json!({});
let cfg = json!({});
let input = ProjectionInput {
spec_result: &data,
template: "{{#if unterminated",
target_form: TargetForm::Prompt,
persona_id: None,
config: &cfg,
};
let out = proj
.render(input)
.await
.expect("engine returns Ok marker, not Err");
assert!(
out.contains("{{render-error:"),
"expected render-error marker, got: {out}"
);
}
#[tokio::test]
async fn static_projection_accepts_optional_persona_id_through_input() {
let proj = StaticProjection::new();
let data = json!({"k": 1});
let cfg = json!({});
let with_persona = ProjectionInput {
spec_result: &data,
template: "v={{k}}",
target_form: TargetForm::Markdown,
persona_id: Some("alice"),
config: &cfg,
};
assert_eq!(proj.render(with_persona).await.unwrap(), "v=1");
let without = ProjectionInput {
spec_result: &data,
template: "v={{k}}",
target_form: TargetForm::Markdown,
persona_id: None,
config: &cfg,
};
assert_eq!(proj.render(without).await.unwrap(), "v=1");
}
struct ReverseMockProjection;
#[async_trait]
impl ProjectionRenderer for ReverseMockProjection {
fn kind(&self) -> &'static str {
"mock"
}
async fn render(&self, input: ProjectionInput<'_>) -> WireResult<String> {
Ok(input.template.chars().rev().collect())
}
}
#[tokio::test]
async fn second_renderer_impl_satisfies_port_contract() {
let mock = ReverseMockProjection;
assert_eq!(mock.kind(), "mock");
let data = json!({});
let cfg = json!({});
let input = ProjectionInput {
spec_result: &data,
template: "abc",
target_form: TargetForm::Json,
persona_id: None,
config: &cfg,
};
assert_eq!(mock.render(input).await.unwrap(), "cba");
}
#[tokio::test]
async fn port_is_dyn_compatible_across_implementations() {
let renderers: Vec<Arc<dyn ProjectionRenderer>> = vec![
Arc::new(StaticProjection::new()),
Arc::new(ReverseMockProjection),
];
let kinds: Vec<&'static str> = renderers.iter().map(|r| r.kind()).collect();
assert_eq!(kinds, vec!["static", "mock"]);
let data = json!({"x": 7});
let cfg = json!({});
for r in &renderers {
let input = ProjectionInput {
spec_result: &data,
template: "tmpl",
target_form: TargetForm::Prompt,
persona_id: None,
config: &cfg,
};
let _ = r.render(input).await.unwrap();
}
}
}