use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EnvelopeMode {
#[default]
None,
Json,
}
impl EnvelopeMode {
pub fn is_json(self) -> bool {
matches!(self, Self::Json)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EnvelopeLayout {
#[default]
Flat,
Nested,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvelopeFields {
pub ok_field: String,
pub format_field: String,
pub content_field: String,
pub meta_field: String,
}
impl Default for EnvelopeFields {
fn default() -> Self {
Self {
ok_field: "ok".into(),
format_field: "format".into(),
content_field: "content".into(),
meta_field: "meta".into(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Meta {
#[serde(skip_serializing_if = "Option::is_none")]
pub dry_run: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(flatten, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, Value>,
}
impl Meta {
pub fn with_dry_run(mut self, value: bool) -> Self {
self.dry_run = Some(value);
self
}
pub fn with_command(mut self, value: String) -> Self {
self.command = Some(value);
self
}
pub fn with_duration_ms(mut self, ms: u64) -> Self {
self.duration_ms = Some(ms);
self
}
pub fn with_timestamp(mut self, ts: String) -> Self {
self.timestamp = Some(ts);
self
}
pub fn with_scope(mut self, scope: String) -> Self {
self.scope = Some(scope);
self
}
pub fn with_version(mut self, version: String) -> Self {
self.version = Some(version);
self
}
pub fn with_extra(mut self, key: impl Into<String>, value: impl Serialize) -> Self {
let v = serde_json::to_value(value).unwrap_or(Value::Null);
self.extra.insert(key.into(), v);
self
}
pub fn with_extra_map(
mut self,
map: impl IntoIterator<Item = (String, Value)>,
) -> Self {
self.extra.extend(map);
self
}
pub fn is_empty(&self) -> bool {
self.dry_run.is_none()
&& self.command.is_none()
&& self.duration_ms.is_none()
&& self.timestamp.is_none()
&& self.scope.is_none()
&& self.version.is_none()
&& self.extra.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvelopeConfig {
pub mode: EnvelopeMode,
pub layout: EnvelopeLayout,
pub fields: EnvelopeFields,
pub show_ok: bool,
pub show_format: bool,
}
impl Default for EnvelopeConfig {
fn default() -> Self {
Self {
mode: EnvelopeMode::default(),
layout: EnvelopeLayout::default(),
fields: EnvelopeFields::default(),
show_ok: true,
show_format: true,
}
}
}
impl EnvelopeConfig {
pub fn with_mode(mut self, mode: EnvelopeMode) -> Self {
self.mode = mode;
self
}
pub fn with_layout(mut self, layout: EnvelopeLayout) -> Self {
self.layout = layout;
self
}
pub fn with_fields(mut self, fields: EnvelopeFields) -> Self {
self.fields = fields;
self
}
pub fn with_show_ok(mut self, show: bool) -> Self {
self.show_ok = show;
self
}
pub fn with_show_format(mut self, show: bool) -> Self {
self.show_format = show;
self
}
}
pub fn wrap(
cfg: &EnvelopeConfig,
format_name: &str,
content: Value,
meta: Option<&Meta>,
ok: bool,
) -> Value {
match cfg.layout {
EnvelopeLayout::Flat => wrap_flat(cfg, format_name, content, meta, ok),
EnvelopeLayout::Nested => wrap_nested(cfg, format_name, content, meta, ok),
}
}
fn wrap_flat(
cfg: &EnvelopeConfig,
format_name: &str,
content: Value,
meta: Option<&Meta>,
ok: bool,
) -> Value {
let mut map = serde_json::Map::new();
if cfg.show_ok {
map.insert(cfg.fields.ok_field.clone(), Value::Bool(ok));
}
if cfg.show_format {
map.insert(
cfg.fields.format_field.clone(),
Value::String(format_name.to_string()),
);
}
let content_key = cfg.fields.content_field.clone();
map.insert(content_key, content);
if let Some(meta) = meta {
if !meta.is_empty() {
if let Ok(meta_val) = serde_json::to_value(meta) {
map.insert(cfg.fields.meta_field.clone(), meta_val);
}
}
}
Value::Object(map)
}
fn wrap_nested(
cfg: &EnvelopeConfig,
format_name: &str,
content: Value,
meta: Option<&Meta>,
ok: bool,
) -> Value {
let mut meta_obj = serde_json::Map::new();
if cfg.show_ok {
meta_obj.insert(cfg.fields.ok_field.clone(), Value::Bool(ok));
}
if cfg.show_format {
meta_obj.insert(
cfg.fields.format_field.clone(),
Value::String(format_name.to_string()),
);
}
if let Some(meta) = meta {
if !meta.is_empty() {
if let Ok(Value::Object(extra)) = serde_json::to_value(meta) {
meta_obj.extend(extra);
}
}
}
let content_key = cfg.fields.content_field.clone();
let mut map = serde_json::Map::new();
map.insert(cfg.fields.meta_field.clone(), Value::Object(meta_obj));
map.insert(content_key, content);
Value::Object(map)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envelope_mode_default_is_none() {
assert_eq!(EnvelopeMode::default(), EnvelopeMode::None);
}
#[test]
fn envelope_mode_is_json() {
assert!(EnvelopeMode::Json.is_json());
assert!(!EnvelopeMode::None.is_json());
}
#[test]
fn envelope_layout_default_is_flat() {
assert_eq!(EnvelopeLayout::default(), EnvelopeLayout::Flat);
}
#[test]
fn envelope_fields_default_names() {
let f = EnvelopeFields::default();
assert_eq!(f.ok_field, "ok");
assert_eq!(f.format_field, "format");
assert_eq!(f.content_field, "content");
assert_eq!(f.meta_field, "meta");
}
#[test]
fn envelope_fields_custom() {
let f = EnvelopeFields {
ok_field: "success".into(),
format_field: "type".into(),
content_field: "result".into(),
meta_field: "context".into(),
};
assert_eq!(f.ok_field, "success");
assert_eq!(f.content_field, "result");
}
#[test]
fn meta_default_is_empty() {
assert!(Meta::default().is_empty());
}
#[test]
fn meta_with_dry_run() {
let m = Meta::default().with_dry_run(true);
assert_eq!(m.dry_run, Some(true));
assert!(!m.is_empty());
}
#[test]
fn meta_with_command() {
let m = Meta::default().with_command("deploy".into());
assert_eq!(m.command, Some("deploy".into()));
}
#[test]
fn meta_with_extra() {
let m = Meta::default().with_extra("region", "eu-west-1");
assert_eq!(m.extra.get("region").unwrap(), "eu-west-1");
}
#[test]
fn meta_with_extra_map_inserts_all_entries() {
let map = [
("region".to_string(), serde_json::json!("eu-west-1")),
("actor".to_string(), serde_json::json!("ci-bot")),
("run_id".to_string(), serde_json::json!(42u64)),
];
let m = Meta::default().with_extra_map(map);
assert_eq!(m.extra.get("region").unwrap(), "eu-west-1");
assert_eq!(m.extra.get("actor").unwrap(), "ci-bot");
assert_eq!(m.extra.get("run_id").unwrap(), 42u64);
assert!(!m.is_empty());
}
#[test]
fn meta_with_extra_map_empty_iterator() {
let m = Meta::default().with_extra_map(std::iter::empty());
assert!(m.extra.is_empty());
}
#[test]
fn meta_with_extra_map_merges_with_existing_extra() {
let m = Meta::default()
.with_extra("a", "first")
.with_extra_map([("b".to_string(), serde_json::json!("second"))]);
assert_eq!(m.extra.get("a").unwrap(), "first");
assert_eq!(m.extra.get("b").unwrap(), "second");
}
#[test]
fn meta_builder_is_fluent() {
let m = Meta::default()
.with_dry_run(true)
.with_command("deploy".into())
.with_duration_ms(120)
.with_timestamp("2026-04-13T00:00:00Z".into())
.with_scope("prod".into())
.with_version("0.3.0".into())
.with_extra("region", "eu-west-1");
assert_eq!(m.dry_run, Some(true));
assert_eq!(m.duration_ms, Some(120));
assert_eq!(m.scope, Some("prod".into()));
assert!(!m.is_empty());
}
#[test]
fn envelope_config_default() {
let cfg = EnvelopeConfig::default();
assert_eq!(cfg.mode, EnvelopeMode::None);
assert_eq!(cfg.layout, EnvelopeLayout::Flat);
assert!(cfg.show_ok);
assert!(cfg.show_format);
}
#[test]
fn envelope_config_builder() {
let cfg = EnvelopeConfig::default()
.with_mode(EnvelopeMode::Json)
.with_layout(EnvelopeLayout::Nested)
.with_show_ok(false)
.with_show_format(false);
assert!(cfg.mode.is_json());
assert_eq!(cfg.layout, EnvelopeLayout::Nested);
assert!(!cfg.show_ok);
assert!(!cfg.show_format);
}
fn default_cfg_json() -> EnvelopeConfig {
EnvelopeConfig::default().with_mode(EnvelopeMode::Json)
}
#[test]
fn wrap_flat_has_ok_and_format() {
let cfg = default_cfg_json();
let content = serde_json::json!({"title": "test"});
let result = wrap(&cfg, "json", content, None, true);
assert_eq!(result["ok"], true);
assert_eq!(result["format"], "json");
assert!(result["content"].is_object());
assert!(result.get("meta").is_none());
}
#[test]
fn wrap_flat_with_meta() {
let cfg = default_cfg_json();
let meta = Meta::default().with_dry_run(true);
let result = wrap(&cfg, "json", serde_json::json!({}), Some(&meta), true);
assert_eq!(result["meta"]["dry_run"], true);
}
#[test]
fn wrap_flat_meta_omitted_when_empty() {
let cfg = default_cfg_json();
let meta = Meta::default();
let result = wrap(&cfg, "json", serde_json::json!({}), Some(&meta), true);
assert!(result.get("meta").is_none());
}
#[test]
fn wrap_flat_hide_ok_and_format() {
let cfg = default_cfg_json()
.with_show_ok(false)
.with_show_format(false);
let result = wrap(&cfg, "json", serde_json::json!({}), None, true);
assert!(result.get("ok").is_none());
assert!(result.get("format").is_none());
assert!(result.get("content").is_some());
}
#[test]
fn wrap_flat_custom_field_names() {
let cfg = default_cfg_json().with_fields(EnvelopeFields {
ok_field: "success".into(),
format_field: "type".into(),
content_field: "result".into(),
meta_field: "context".into(),
});
let result = wrap(&cfg, "json", serde_json::json!({}), None, true);
assert_eq!(result["success"], true);
assert_eq!(result["type"], "json");
assert!(result.get("result").is_some());
}
#[test]
fn wrap_nested_puts_ok_format_under_meta() {
let cfg = default_cfg_json().with_layout(EnvelopeLayout::Nested);
let result = wrap(&cfg, "json", serde_json::json!({"x": 1}), None, true);
assert_eq!(result["meta"]["ok"], true);
assert_eq!(result["meta"]["format"], "json");
assert!(result["content"].is_object());
assert!(result.get("ok").is_none());
}
#[test]
fn wrap_nested_merges_user_meta_into_meta_object() {
let cfg = default_cfg_json().with_layout(EnvelopeLayout::Nested);
let meta = Meta::default()
.with_dry_run(true)
.with_timestamp("2026-04-13T00:00:00Z".into());
let result = wrap(&cfg, "json", serde_json::json!({}), Some(&meta), true);
assert_eq!(result["meta"]["dry_run"], true);
assert_eq!(result["meta"]["timestamp"], "2026-04-13T00:00:00Z");
assert_eq!(result["meta"]["ok"], true);
}
#[test]
fn wrap_nested_custom_field_names() {
let cfg = default_cfg_json()
.with_layout(EnvelopeLayout::Nested)
.with_fields(EnvelopeFields {
ok_field: "success".into(),
format_field: "type".into(),
content_field: "data".into(),
meta_field: "header".into(),
});
let result = wrap(&cfg, "text", serde_json::json!({"x": 1}), None, false);
assert_eq!(result["header"]["success"], false);
assert_eq!(result["header"]["type"], "text");
assert!(result.get("data").is_some());
}
#[test]
fn wrap_nested_with_extra_in_meta() {
let cfg = default_cfg_json().with_layout(EnvelopeLayout::Nested);
let meta = Meta::default().with_extra("region", "eu-west-1");
let result = wrap(&cfg, "json", serde_json::json!({}), Some(&meta), true);
assert_eq!(result["meta"]["region"], "eu-west-1");
}
}