use std::fmt;
use serde::{Deserialize, Serialize};
use crate::domain::error::{DomainError, WireResult};
use crate::domain::graph::Ulid;
pub type BundleId = Ulid;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct BundleName(String);
impl BundleName {
pub fn new(value: impl Into<String>) -> WireResult<Self> {
let s = value.into();
if s.is_empty() {
return Err(
DomainError::ConstraintViolation("bundle name must not be empty".into()).into(),
);
}
Ok(Self(s))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl fmt::Display for BundleName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for BundleName {
fn deserialize<D>(d: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(d)?;
BundleName::new(s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct BundleVersion(String);
impl BundleVersion {
pub fn new(value: impl Into<String>) -> WireResult<Self> {
let s = value.into();
if s.is_empty() {
return Err(DomainError::ConstraintViolation(
"bundle version must not be empty".into(),
)
.into());
}
Ok(Self(s))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl fmt::Display for BundleVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for BundleVersion {
fn deserialize<D>(d: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(d)?;
BundleVersion::new(s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Bundle {
pub id: BundleId,
pub name: BundleName,
pub version: BundleVersion,
pub description: Option<String>,
pub body: String,
pub created_at: i64,
pub updated_at: i64,
}
impl Bundle {
pub fn new(
id: BundleId,
name: BundleName,
version: BundleVersion,
description: Option<String>,
body: impl Into<String>,
created_at: i64,
updated_at: i64,
) -> WireResult<Self> {
let body = body.into();
if body.is_empty() {
return Err(
DomainError::ConstraintViolation("bundle body must not be empty".into()).into(),
);
}
Ok(Self {
id,
name,
version,
description,
body,
created_at,
updated_at,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ConflictMode {
#[default]
Increment,
Skip,
Error,
}
impl fmt::Display for ConflictMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Increment => "increment",
Self::Skip => "skip",
Self::Error => "error",
};
f.write_str(s)
}
}
impl ConflictMode {
pub fn parse(s: &str) -> WireResult<Self> {
match s.to_ascii_lowercase().as_str() {
"increment" => Ok(Self::Increment),
"skip" => Ok(Self::Skip),
"error" => Ok(Self::Error),
other => Err(DomainError::ConstraintViolation(format!(
"unknown conflict mode: {} (expected increment/skip/error)",
other
))
.into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BundleRef {
Id(BundleId),
Name(BundleName),
}
impl BundleRef {
pub fn parse(s: &str) -> WireResult<Self> {
if let Ok(id) = Ulid::from_string(s) {
return Ok(Self::Id(id));
}
BundleName::new(s).map(Self::Name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstalledItem {
pub kind: String,
pub original_name: String,
pub final_name: String,
pub id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkippedItem {
pub kind: String,
pub name: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ErrorItem {
pub kind: String,
pub name: String,
pub error: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BundleInstallReport {
pub install_id: String,
pub bundle_id: String,
pub mode: ConflictMode,
pub installed: Vec<InstalledItem>,
pub skipped: Vec<SkippedItem>,
pub errors: Vec<ErrorItem>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundle_name_rejects_empty() {
assert!(BundleName::new("").is_err());
assert!(BundleName::new("quickstart").is_ok());
}
#[test]
fn bundle_version_rejects_empty() {
assert!(BundleVersion::new("").is_err());
assert!(BundleVersion::new("0.1.0").is_ok());
}
#[test]
fn bundle_rejects_empty_body() {
let id = Ulid::new();
let name = BundleName::new("quickstart").unwrap();
let ver = BundleVersion::new("0.1.0").unwrap();
assert!(Bundle::new(id, name.clone(), ver.clone(), None, "", 0, 0).is_err());
assert!(Bundle::new(id, name, ver, None, "[bundle]\nname=\"x\"", 0, 0).is_ok());
}
#[test]
fn conflict_mode_parse_roundtrip() {
assert_eq!(
ConflictMode::parse("increment").unwrap(),
ConflictMode::Increment
);
assert_eq!(ConflictMode::parse("SKIP").unwrap(), ConflictMode::Skip);
assert_eq!(ConflictMode::parse("Error").unwrap(), ConflictMode::Error);
assert!(ConflictMode::parse("force").is_err());
}
#[test]
fn conflict_mode_default_is_increment() {
assert_eq!(ConflictMode::default(), ConflictMode::Increment);
}
#[test]
fn bundle_ref_parses_ulid_first_then_name() {
let id = Ulid::new();
match BundleRef::parse(&id.to_string()).unwrap() {
BundleRef::Id(parsed) => assert_eq!(parsed, id),
BundleRef::Name(_) => panic!("expected Id"),
}
match BundleRef::parse("quickstart").unwrap() {
BundleRef::Name(n) => assert_eq!(n.as_str(), "quickstart"),
BundleRef::Id(_) => panic!("expected Name"),
}
assert!(BundleRef::parse("").is_err());
}
}