use std::fmt;
use serde::{Deserialize, Serialize};
use crate::domain::error::{DomainError, WireResult};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct ProjectionName(String);
impl ProjectionName {
pub fn new(value: impl Into<String>) -> WireResult<Self> {
let s = value.into();
if s.is_empty() {
return Err(
DomainError::InvalidProjection("projection name must not be empty".into()).into(),
);
}
Ok(Self(s))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for ProjectionName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for ProjectionName {
fn as_ref(&self) -> &str {
&self.0
}
}
impl From<ProjectionName> for String {
fn from(value: ProjectionName) -> Self {
value.0
}
}
impl TryFrom<String> for ProjectionName {
type Error = crate::domain::error::WireError;
fn try_from(value: String) -> WireResult<Self> {
Self::new(value)
}
}
impl TryFrom<&str> for ProjectionName {
type Error = crate::domain::error::WireError;
fn try_from(value: &str) -> WireResult<Self> {
Self::new(value.to_owned())
}
}
impl<'de> Deserialize<'de> for ProjectionName {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Self::new(raw).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct SpecName(String);
impl SpecName {
pub fn new(value: impl Into<String>) -> WireResult<Self> {
let s = value.into();
if s.is_empty() {
return Err(
DomainError::InvalidProjection("spec_name must not be empty".into()).into(),
);
}
Ok(Self(s))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
pub type SpecRef = SpecName;
impl fmt::Display for SpecName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for SpecName {
fn as_ref(&self) -> &str {
&self.0
}
}
impl From<SpecName> for String {
fn from(value: SpecName) -> Self {
value.0
}
}
impl TryFrom<String> for SpecName {
type Error = crate::domain::error::WireError;
fn try_from(value: String) -> WireResult<Self> {
Self::new(value)
}
}
impl TryFrom<&str> for SpecName {
type Error = crate::domain::error::WireError;
fn try_from(value: &str) -> WireResult<Self> {
Self::new(value.to_owned())
}
}
impl<'de> Deserialize<'de> for SpecName {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Self::new(raw).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct ProjectionTemplate(String);
impl ProjectionTemplate {
pub fn new(value: impl Into<String>) -> WireResult<Self> {
let s = value.into();
if s.is_empty() {
return Err(DomainError::InvalidProjection("template must not be empty".into()).into());
}
Ok(Self(s))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for ProjectionTemplate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for ProjectionTemplate {
fn as_ref(&self) -> &str {
&self.0
}
}
impl From<ProjectionTemplate> for String {
fn from(value: ProjectionTemplate) -> Self {
value.0
}
}
impl TryFrom<String> for ProjectionTemplate {
type Error = crate::domain::error::WireError;
fn try_from(value: String) -> WireResult<Self> {
Self::new(value)
}
}
impl TryFrom<&str> for ProjectionTemplate {
type Error = crate::domain::error::WireError;
fn try_from(value: &str) -> WireResult<Self> {
Self::new(value.to_owned())
}
}
impl<'de> Deserialize<'de> for ProjectionTemplate {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Self::new(raw).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum TargetForm {
Prompt,
Markdown,
Json,
Ascii,
}
impl TargetForm {
pub fn as_str(self) -> &'static str {
match self {
TargetForm::Prompt => "prompt",
TargetForm::Markdown => "markdown",
TargetForm::Json => "json",
TargetForm::Ascii => "ascii",
}
}
pub fn parse(s: &str) -> WireResult<Self> {
match s {
"prompt" => Ok(TargetForm::Prompt),
"markdown" => Ok(TargetForm::Markdown),
"json" => Ok(TargetForm::Json),
"ascii" => Ok(TargetForm::Ascii),
other => {
Err(DomainError::InvalidTargetForm(format!("unknown target_form: {other}")).into())
}
}
}
}
impl fmt::Display for TargetForm {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "dispatch")]
pub enum PluginDispatch {
Default,
Custom {
engine: String,
kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
config: Option<serde_json::Value>,
},
}
impl PluginDispatch {
pub const fn default_dispatch() -> Self {
Self::Default
}
pub fn custom(
engine: impl Into<String>,
kind: impl Into<String>,
config: Option<serde_json::Value>,
) -> WireResult<Self> {
let engine = engine.into();
let kind = kind.into();
if engine.is_empty() {
return Err(DomainError::InvalidProjection(
"PluginDispatch::Custom.engine must not be empty".into(),
)
.into());
}
if kind.is_empty() {
return Err(DomainError::InvalidProjection(
"PluginDispatch::Custom.kind must not be empty".into(),
)
.into());
}
Ok(Self::Custom {
engine,
kind,
config,
})
}
pub fn from_optional_parts(
engine: Option<String>,
kind: Option<String>,
config: Option<serde_json::Value>,
) -> WireResult<Self> {
match (engine, kind, config) {
(None, None, None) => Ok(Self::Default),
(Some(e), Some(k), cfg) => Self::custom(e, k, cfg),
(None, None, Some(_)) => Err(DomainError::InvalidProjection(
"projection_config present without template_engine + projection_kind".into(),
)
.into()),
(Some(_), None, _) => Err(DomainError::InvalidProjection(
"template_engine present without projection_kind".into(),
)
.into()),
(None, Some(_), _) => Err(DomainError::InvalidProjection(
"projection_kind present without template_engine".into(),
)
.into()),
}
}
pub fn to_optional_parts(&self) -> (Option<&str>, Option<&str>, Option<&serde_json::Value>) {
match self {
Self::Default => (None, None, None),
Self::Custom {
engine,
kind,
config,
} => (Some(engine.as_str()), Some(kind.as_str()), config.as_ref()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Projection {
name: ProjectionName,
spec_ref: SpecName,
template: ProjectionTemplate,
target_form: TargetForm,
plugin: PluginDispatch,
}
impl Projection {
pub fn new(
name: ProjectionName,
spec_ref: SpecName,
template: ProjectionTemplate,
target_form: TargetForm,
plugin: PluginDispatch,
) -> Self {
Self {
name,
spec_ref,
template,
target_form,
plugin,
}
}
pub fn from_parts(
name: impl Into<String>,
spec_ref: impl Into<String>,
template: impl Into<String>,
target_form: TargetForm,
plugin: PluginDispatch,
) -> WireResult<Self> {
Ok(Self::new(
ProjectionName::new(name)?,
SpecName::new(spec_ref)?,
ProjectionTemplate::new(template)?,
target_form,
plugin,
))
}
pub fn name(&self) -> &ProjectionName {
&self.name
}
pub fn spec_ref(&self) -> &SpecName {
&self.spec_ref
}
pub fn template(&self) -> &ProjectionTemplate {
&self.template
}
pub fn target_form(&self) -> TargetForm {
self.target_form
}
pub fn plugin(&self) -> &PluginDispatch {
&self.plugin
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::error::WireError;
#[test]
fn projection_name_accepts_valid() {
let n = ProjectionName::new("_persona_toc").unwrap();
assert_eq!(n.as_str(), "_persona_toc");
assert_eq!(n.to_string(), "_persona_toc");
}
#[test]
fn projection_name_rejects_empty() {
let err = ProjectionName::new("").expect_err("empty must reject");
assert!(matches!(
err,
WireError::Domain(DomainError::InvalidProjection(_))
));
}
#[test]
fn projection_name_serde_roundtrip() {
let n = ProjectionName::new("foo").unwrap();
let json = serde_json::to_string(&n).unwrap();
assert_eq!(json, "\"foo\"");
let back: ProjectionName = serde_json::from_str(&json).unwrap();
assert_eq!(back, n);
}
#[test]
fn projection_name_serde_rejects_empty() {
let err = serde_json::from_str::<ProjectionName>("\"\"").expect_err("reject");
assert!(err.to_string().contains("must not be empty"));
}
#[test]
fn spec_name_accepts_valid() {
let r = SpecName::new("active_personas").unwrap();
assert_eq!(r.as_str(), "active_personas");
}
#[test]
fn spec_name_rejects_empty() {
let err = SpecName::new("").expect_err("reject");
assert!(matches!(
err,
WireError::Domain(DomainError::InvalidProjection(_))
));
}
#[test]
fn spec_name_serde_roundtrip() {
let r = SpecName::new("s").unwrap();
let back: SpecName = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
assert_eq!(back, r);
}
#[test]
fn spec_ref_alias_resolves_to_spec_name() {
let r: SpecRef = SpecName::new("active_personas").unwrap();
assert_eq!(r.as_str(), "active_personas");
}
#[test]
fn projection_template_accepts_valid() {
let t = ProjectionTemplate::new("hello {{name}}").unwrap();
assert_eq!(t.as_str(), "hello {{name}}");
}
#[test]
fn projection_template_rejects_empty() {
let err = ProjectionTemplate::new("").expect_err("reject");
assert!(matches!(
err,
WireError::Domain(DomainError::InvalidProjection(_))
));
}
#[test]
fn projection_name_string_surface_roundtrip() {
let raw = "active_personas";
let n = ProjectionName::new(raw).unwrap();
assert_eq!(n.to_string(), raw); assert_eq!(<ProjectionName as AsRef<str>>::as_ref(&n), raw); let back: String = n.into();
assert_eq!(back, raw); }
#[test]
fn spec_name_string_surface_roundtrip() {
let raw = "active_personas";
let n = SpecName::new(raw).unwrap();
assert_eq!(n.to_string(), raw);
assert_eq!(<SpecName as AsRef<str>>::as_ref(&n), raw);
let back: String = n.into();
assert_eq!(back, raw);
}
#[test]
fn projection_template_string_surface_and_serde_roundtrip() {
let raw = "hello {{persona}}";
let t = ProjectionTemplate::new(raw).unwrap();
assert_eq!(t.to_string(), raw);
assert_eq!(<ProjectionTemplate as AsRef<str>>::as_ref(&t), raw);
let json = serde_json::to_string(&t).unwrap();
let parsed: ProjectionTemplate = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, t);
let back: String = t.into();
assert_eq!(back, raw);
}
#[test]
fn projection_template_serde_rejects_empty() {
let err = serde_json::from_str::<ProjectionTemplate>("\"\"")
.expect_err("empty must reject through deserialize");
assert!(err.to_string().contains("template must not be empty"));
}
#[test]
fn target_form_parse_all_variants() {
assert_eq!(TargetForm::parse("prompt").unwrap(), TargetForm::Prompt);
assert_eq!(TargetForm::parse("markdown").unwrap(), TargetForm::Markdown);
assert_eq!(TargetForm::parse("json").unwrap(), TargetForm::Json);
assert_eq!(TargetForm::parse("ascii").unwrap(), TargetForm::Ascii);
}
#[test]
fn target_form_parse_rejects_unknown() {
let err = TargetForm::parse("yaml").expect_err("reject");
assert!(matches!(
err,
WireError::Domain(DomainError::InvalidTargetForm(_))
));
}
#[test]
fn target_form_as_str_roundtrip() {
for v in [
TargetForm::Prompt,
TargetForm::Markdown,
TargetForm::Json,
TargetForm::Ascii,
] {
assert_eq!(TargetForm::parse(v.as_str()).unwrap(), v);
}
}
#[test]
fn plugin_dispatch_default() {
let d = PluginDispatch::default_dispatch();
assert_eq!(d, PluginDispatch::Default);
assert_eq!(d.to_optional_parts(), (None, None, None));
}
#[test]
fn plugin_dispatch_custom_validates_non_empty() {
let err = PluginDispatch::custom("", "static", None).expect_err("engine empty");
assert!(matches!(
err,
WireError::Domain(DomainError::InvalidProjection(_))
));
let err = PluginDispatch::custom("handlebars", "", None).expect_err("kind empty");
assert!(matches!(
err,
WireError::Domain(DomainError::InvalidProjection(_))
));
}
#[test]
fn plugin_dispatch_from_optional_parts_default() {
let d = PluginDispatch::from_optional_parts(None, None, None).unwrap();
assert_eq!(d, PluginDispatch::Default);
}
#[test]
fn plugin_dispatch_from_optional_parts_custom() {
let d = PluginDispatch::from_optional_parts(
Some("handlebars".into()),
Some("static".into()),
None,
)
.unwrap();
assert!(matches!(d, PluginDispatch::Custom { .. }));
}
#[test]
fn plugin_dispatch_from_optional_parts_rejects_illegal() {
assert!(matches!(
PluginDispatch::from_optional_parts(Some("h".into()), None, None).unwrap_err(),
WireError::Domain(DomainError::InvalidProjection(_))
));
assert!(matches!(
PluginDispatch::from_optional_parts(None, Some("s".into()), None).unwrap_err(),
WireError::Domain(DomainError::InvalidProjection(_))
));
assert!(matches!(
PluginDispatch::from_optional_parts(None, None, Some(serde_json::json!({})))
.unwrap_err(),
WireError::Domain(DomainError::InvalidProjection(_))
));
}
#[test]
fn plugin_dispatch_to_optional_parts_custom() {
let cfg = serde_json::json!({"k": 1});
let d = PluginDispatch::custom("handlebars", "llm", Some(cfg.clone())).unwrap();
let (e, k, c) = d.to_optional_parts();
assert_eq!(e, Some("handlebars"));
assert_eq!(k, Some("llm"));
assert_eq!(c, Some(&cfg));
}
#[test]
fn plugin_dispatch_serde_default_roundtrip() {
let d = PluginDispatch::Default;
let json = serde_json::to_string(&d).unwrap();
let back: PluginDispatch = serde_json::from_str(&json).unwrap();
assert_eq!(back, d);
}
#[test]
fn plugin_dispatch_serde_custom_roundtrip() {
let d = PluginDispatch::custom("handlebars", "static", None).unwrap();
let json = serde_json::to_string(&d).unwrap();
let back: PluginDispatch = serde_json::from_str(&json).unwrap();
assert_eq!(back, d);
}
#[test]
fn projection_from_parts_accepts_valid() {
let p = Projection::from_parts(
"_persona_toc",
"active_personas",
"Active: {{count}}",
TargetForm::Prompt,
PluginDispatch::Default,
)
.unwrap();
assert_eq!(p.name().as_str(), "_persona_toc");
assert_eq!(p.spec_ref().as_str(), "active_personas");
assert_eq!(p.template().as_str(), "Active: {{count}}");
assert_eq!(p.target_form(), TargetForm::Prompt);
assert_eq!(p.plugin(), &PluginDispatch::Default);
}
#[test]
fn projection_from_parts_propagates_vo_errors() {
let err = Projection::from_parts(
"",
"spec",
"tmpl",
TargetForm::Prompt,
PluginDispatch::Default,
)
.expect_err("empty name");
assert!(matches!(
err,
WireError::Domain(DomainError::InvalidProjection(_))
));
let err =
Projection::from_parts("n", "", "tmpl", TargetForm::Prompt, PluginDispatch::Default)
.expect_err("empty spec_ref");
assert!(matches!(
err,
WireError::Domain(DomainError::InvalidProjection(_))
));
let err = Projection::from_parts("n", "s", "", TargetForm::Prompt, PluginDispatch::Default)
.expect_err("empty template");
assert!(matches!(
err,
WireError::Domain(DomainError::InvalidProjection(_))
));
}
#[test]
fn projection_immutable_equality() {
let p1 =
Projection::from_parts("n", "s", "t", TargetForm::Markdown, PluginDispatch::Default)
.unwrap();
let p2 =
Projection::from_parts("n", "s", "t", TargetForm::Markdown, PluginDispatch::Default)
.unwrap();
assert_eq!(p1, p2);
}
#[test]
fn projection_new_typed_vo_path_assembles() {
let p = Projection::new(
ProjectionName::new("toc").unwrap(),
SpecName::new("active_personas").unwrap(),
ProjectionTemplate::new("Count: {{n}}").unwrap(),
TargetForm::Json,
PluginDispatch::custom("handlebars", "llm", None).unwrap(),
);
assert_eq!(p.name().as_str(), "toc");
assert_eq!(p.spec_ref().as_str(), "active_personas");
assert_eq!(p.template().as_str(), "Count: {{n}}");
assert_eq!(p.target_form(), TargetForm::Json);
assert!(matches!(p.plugin(), PluginDispatch::Custom { .. }));
}
#[test]
fn plugin_dispatch_from_optional_parts_rejects_engine_with_config() {
let err = PluginDispatch::from_optional_parts(
Some("handlebars".into()),
None,
Some(serde_json::json!({"x": 1})),
)
.expect_err("engine + config without kind must reject");
assert!(matches!(
err,
WireError::Domain(DomainError::InvalidProjection(_))
));
}
#[test]
fn plugin_dispatch_from_optional_parts_rejects_kind_with_config() {
let err = PluginDispatch::from_optional_parts(
None,
Some("static".into()),
Some(serde_json::json!({"x": 1})),
)
.expect_err("kind + config without engine must reject");
assert!(matches!(
err,
WireError::Domain(DomainError::InvalidProjection(_))
));
}
}