use crate::envelope::xml_escape;
use std::borrow::Cow;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MetadataType(Cow<'static, str>);
impl MetadataType {
pub fn new(name: impl Into<Cow<'static, str>>) -> Self {
Self(name.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for MetadataType {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for MetadataType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl From<&'static str> for MetadataType {
fn from(s: &'static str) -> Self {
Self(Cow::Borrowed(s))
}
}
impl From<String> for MetadataType {
fn from(s: String) -> Self {
Self(Cow::Owned(s))
}
}
impl MetadataType {
pub const APEX_CLASS: MetadataType = MetadataType(Cow::Borrowed("ApexClass"));
pub const APEX_COMPONENT: MetadataType = MetadataType(Cow::Borrowed("ApexComponent"));
pub const APEX_PAGE: MetadataType = MetadataType(Cow::Borrowed("ApexPage"));
pub const APEX_TRIGGER: MetadataType = MetadataType(Cow::Borrowed("ApexTrigger"));
pub const APEX_TEST_SUITE: MetadataType = MetadataType(Cow::Borrowed("ApexTestSuite"));
pub const CUSTOM_OBJECT: MetadataType = MetadataType(Cow::Borrowed("CustomObject"));
pub const CUSTOM_FIELD: MetadataType = MetadataType(Cow::Borrowed("CustomField"));
pub const CUSTOM_TAB: MetadataType = MetadataType(Cow::Borrowed("CustomTab"));
pub const CUSTOM_APPLICATION: MetadataType = MetadataType(Cow::Borrowed("CustomApplication"));
pub const CUSTOM_LABELS: MetadataType = MetadataType(Cow::Borrowed("CustomLabels"));
pub const CUSTOM_METADATA: MetadataType = MetadataType(Cow::Borrowed("CustomMetadata"));
pub const CUSTOM_OBJECT_TRANSLATION: MetadataType =
MetadataType(Cow::Borrowed("CustomObjectTranslation"));
pub const TRANSLATIONS: MetadataType = MetadataType(Cow::Borrowed("Translations"));
pub const STANDARD_VALUE_SET: MetadataType = MetadataType(Cow::Borrowed("StandardValueSet"));
pub const GLOBAL_VALUE_SET: MetadataType = MetadataType(Cow::Borrowed("GlobalValueSet"));
pub const RECORD_TYPE: MetadataType = MetadataType(Cow::Borrowed("RecordType"));
pub const LAYOUT: MetadataType = MetadataType(Cow::Borrowed("Layout"));
pub const LIST_VIEW: MetadataType = MetadataType(Cow::Borrowed("ListView"));
pub const FIELD_SET: MetadataType = MetadataType(Cow::Borrowed("FieldSet"));
pub const VALIDATION_RULE: MetadataType = MetadataType(Cow::Borrowed("ValidationRule"));
pub const WEB_LINK: MetadataType = MetadataType(Cow::Borrowed("WebLink"));
pub const QUICK_ACTION: MetadataType = MetadataType(Cow::Borrowed("QuickAction"));
pub const PROFILE: MetadataType = MetadataType(Cow::Borrowed("Profile"));
pub const PERMISSION_SET: MetadataType = MetadataType(Cow::Borrowed("PermissionSet"));
pub const PERMISSION_SET_GROUP: MetadataType =
MetadataType(Cow::Borrowed("PermissionSetGroup"));
pub const ROLE: MetadataType = MetadataType(Cow::Borrowed("Role"));
pub const GROUP: MetadataType = MetadataType(Cow::Borrowed("Group"));
pub const QUEUE: MetadataType = MetadataType(Cow::Borrowed("Queue"));
pub const SHARING_RULES: MetadataType = MetadataType(Cow::Borrowed("SharingRules"));
pub const FLOW: MetadataType = MetadataType(Cow::Borrowed("Flow"));
pub const FLOW_DEFINITION: MetadataType = MetadataType(Cow::Borrowed("FlowDefinition"));
pub const WORKFLOW: MetadataType = MetadataType(Cow::Borrowed("Workflow"));
pub const APPROVAL_PROCESS: MetadataType = MetadataType(Cow::Borrowed("ApprovalProcess"));
pub const REPORT: MetadataType = MetadataType(Cow::Borrowed("Report"));
pub const REPORT_TYPE: MetadataType = MetadataType(Cow::Borrowed("ReportType"));
pub const DASHBOARD: MetadataType = MetadataType(Cow::Borrowed("Dashboard"));
pub const DOCUMENT: MetadataType = MetadataType(Cow::Borrowed("Document"));
pub const EMAIL_TEMPLATE: MetadataType = MetadataType(Cow::Borrowed("EmailTemplate"));
pub const LIGHTNING_COMPONENT_BUNDLE: MetadataType =
MetadataType(Cow::Borrowed("LightningComponentBundle"));
pub const AURA_DEFINITION_BUNDLE: MetadataType =
MetadataType(Cow::Borrowed("AuraDefinitionBundle"));
pub const STATIC_RESOURCE: MetadataType = MetadataType(Cow::Borrowed("StaticResource"));
pub const CONTENT_ASSET: MetadataType = MetadataType(Cow::Borrowed("ContentAsset"));
pub const CONNECTED_APP: MetadataType = MetadataType(Cow::Borrowed("ConnectedApp"));
pub const NAMED_CREDENTIAL: MetadataType = MetadataType(Cow::Borrowed("NamedCredential"));
pub const AUTH_PROVIDER: MetadataType = MetadataType(Cow::Borrowed("AuthProvider"));
pub const REMOTE_SITE_SETTING: MetadataType = MetadataType(Cow::Borrowed("RemoteSiteSetting"));
}
#[derive(Debug, Clone)]
pub struct PackageManifest {
api_version: String,
full_name: Option<String>,
entries: Vec<TypeEntry>,
}
#[derive(Debug, Clone)]
struct TypeEntry {
type_name: String,
members: Vec<String>,
}
impl PackageManifest {
pub fn new(api_version: impl Into<String>) -> Self {
Self {
api_version: api_version.into(),
full_name: None,
entries: Vec::new(),
}
}
pub fn full_name(mut self, name: impl Into<String>) -> Self {
self.full_name = Some(name.into());
self
}
pub fn add<T, M, S>(mut self, type_name: T, members: M) -> Self
where
T: Into<MetadataType>,
M: IntoIterator<Item = S>,
S: Into<String>,
{
let type_name: MetadataType = type_name.into();
let new_members = members.into_iter().map(Into::into);
if let Some(entry) = self
.entries
.iter_mut()
.find(|e| e.type_name == type_name.as_str())
{
entry.members.extend(new_members);
} else {
self.entries.push(TypeEntry {
type_name: type_name.as_str().to_string(),
members: new_members.collect(),
});
}
self
}
pub fn all<T: Into<MetadataType>>(mut self, type_name: T) -> Self {
let type_name: MetadataType = type_name.into();
if let Some(entry) = self
.entries
.iter_mut()
.find(|e| e.type_name == type_name.as_str())
{
entry.members.clear();
entry.members.push("*".to_string());
} else {
self.entries.push(TypeEntry {
type_name: type_name.as_str().to_string(),
members: vec!["*".to_string()],
});
}
self
}
pub fn api_version(&self) -> &str {
&self.api_version
}
pub fn full_name_str(&self) -> Option<&str> {
self.full_name.as_deref()
}
pub fn type_count(&self) -> usize {
self.entries.len()
}
pub fn entries(&self) -> impl Iterator<Item = (&str, &[String])> {
self.entries
.iter()
.map(|e| (e.type_name.as_str(), e.members.as_slice()))
}
pub fn to_xml(&self) -> String {
let mut out = String::with_capacity(128 + self.entries.len() * 64);
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
out.push_str("<Package xmlns=\"http://soap.sforce.com/2006/04/metadata\">\n");
if let Some(full) = &self.full_name {
out.push_str(" <fullName>");
out.push_str(&xml_escape(full));
out.push_str("</fullName>\n");
}
for entry in &self.entries {
if entry.members.is_empty() {
continue;
}
out.push_str(" <types>\n");
for member in &entry.members {
out.push_str(" <members>");
out.push_str(&xml_escape(member));
out.push_str("</members>\n");
}
out.push_str(" <name>");
out.push_str(&xml_escape(&entry.type_name));
out.push_str("</name>\n");
out.push_str(" </types>\n");
}
out.push_str(" <version>");
out.push_str(&xml_escape(&self.api_version));
out.push_str("</version>\n");
out.push_str("</Package>\n");
out
}
pub(crate) fn render_soap_inner(&self) -> String {
let mut out = String::with_capacity(64 + self.entries.len() * 64);
if let Some(full) = &self.full_name {
out.push_str("<met:fullName>");
out.push_str(&xml_escape(full));
out.push_str("</met:fullName>");
}
for entry in &self.entries {
if entry.members.is_empty() {
continue;
}
out.push_str("<met:types>");
for member in &entry.members {
out.push_str("<met:members>");
out.push_str(&xml_escape(member));
out.push_str("</met:members>");
}
out.push_str("<met:name>");
out.push_str(&xml_escape(&entry.type_name));
out.push_str("</met:name>");
out.push_str("</met:types>");
}
out.push_str("<met:version>");
out.push_str(&xml_escape(&self.api_version));
out.push_str("</met:version>");
out
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn metadata_type_constants_round_trip() {
assert_eq!(MetadataType::APEX_CLASS.as_str(), "ApexClass");
assert_eq!(MetadataType::CUSTOM_OBJECT.as_str(), "CustomObject");
assert_eq!(MetadataType::PROFILE.as_str(), "Profile");
assert_eq!(MetadataType::FLOW.as_str(), "Flow");
}
#[test]
fn metadata_type_new_accepts_arbitrary_names() {
let t = MetadataType::new("FrobnozzWidget");
assert_eq!(t.as_str(), "FrobnozzWidget");
let t2 = MetadataType::new(String::from("OwnedString"));
assert_eq!(t2.as_str(), "OwnedString");
}
#[test]
fn metadata_type_into_from_static_str() {
let t: MetadataType = "MyType".into();
assert_eq!(t.as_str(), "MyType");
}
#[test]
fn metadata_type_implements_display() {
assert_eq!(MetadataType::APEX_CLASS.to_string(), "ApexClass");
}
#[test]
fn manifest_empty_emits_just_version() {
let pkg = PackageManifest::new("66.0");
let xml = pkg.to_xml();
assert!(xml.contains("<?xml version=\"1.0\""));
assert!(xml.contains("<Package xmlns=\"http://soap.sforce.com/2006/04/metadata\">"));
assert!(xml.contains("<version>66.0</version>"));
assert!(xml.contains("</Package>"));
assert!(!xml.contains("<types>"));
}
#[test]
fn manifest_emits_types_in_insertion_order() {
let pkg = PackageManifest::new("66.0")
.add(MetadataType::APEX_CLASS, ["Foo", "Bar"])
.add(MetadataType::CUSTOM_OBJECT, ["Account__c"]);
let xml = pkg.to_xml();
let i_apex = xml.find("<name>ApexClass</name>").unwrap();
let i_obj = xml.find("<name>CustomObject</name>").unwrap();
assert!(i_apex < i_obj);
assert!(xml.contains("<members>Foo</members>"));
assert!(xml.contains("<members>Bar</members>"));
assert!(xml.contains("<members>Account__c</members>"));
}
#[test]
fn manifest_merges_repeated_type_adds() {
let pkg = PackageManifest::new("66.0")
.add(MetadataType::APEX_CLASS, ["Foo"])
.add(MetadataType::CUSTOM_OBJECT, ["Acct__c"])
.add(MetadataType::APEX_CLASS, ["Bar", "Baz"]);
let xml = pkg.to_xml();
assert_eq!(xml.matches("<name>ApexClass</name>").count(), 1);
let apex_section = {
let start = xml.find("<types>").unwrap();
let end = xml[start..].find("</types>").unwrap() + start;
&xml[start..=end]
};
assert!(apex_section.contains("Foo"));
assert!(apex_section.contains("Bar"));
assert!(apex_section.contains("Baz"));
assert_eq!(pkg.type_count(), 2);
}
#[test]
fn manifest_all_emits_wildcard_member() {
let pkg = PackageManifest::new("66.0").all(MetadataType::CUSTOM_TAB);
let xml = pkg.to_xml();
assert!(xml.contains("<members>*</members>"));
assert!(xml.contains("<name>CustomTab</name>"));
}
#[test]
fn manifest_all_replaces_prior_explicit_members() {
let pkg = PackageManifest::new("66.0")
.add(MetadataType::APEX_CLASS, ["Foo", "Bar"])
.all(MetadataType::APEX_CLASS);
let xml = pkg.to_xml();
assert_eq!(xml.matches("<members>*</members>").count(), 1);
assert!(!xml.contains("<members>Foo</members>"));
assert!(!xml.contains("<members>Bar</members>"));
}
#[test]
fn manifest_repeated_all_collapses_to_single_wildcard() {
let pkg = PackageManifest::new("66.0")
.all(MetadataType::APEX_CLASS)
.all(MetadataType::APEX_CLASS);
let xml = pkg.to_xml();
assert_eq!(xml.matches("<members>*</members>").count(), 1);
}
#[test]
fn manifest_escapes_special_xml_chars_in_members() {
let pkg = PackageManifest::new("66.0").add(MetadataType::APEX_CLASS, ["Foo<&>"]);
let xml = pkg.to_xml();
assert!(xml.contains("<members>Foo<&></members>"));
assert!(!xml.contains("Foo<&>"));
}
#[test]
fn manifest_skips_types_with_empty_member_lists() {
let pkg = PackageManifest::new("66.0")
.add(MetadataType::APEX_CLASS, Vec::<String>::new())
.add(MetadataType::CUSTOM_OBJECT, ["Foo__c"]);
let xml = pkg.to_xml();
assert!(!xml.contains("<name>ApexClass</name>"));
assert!(xml.contains("<name>CustomObject</name>"));
}
#[test]
fn manifest_full_name_emitted_for_packaged_variant() {
let pkg = PackageManifest::new("66.0")
.full_name("MyManagedPackage")
.add(MetadataType::APEX_CLASS, ["Foo"]);
let xml = pkg.to_xml();
assert!(xml.contains("<fullName>MyManagedPackage</fullName>"));
let i_full = xml.find("<fullName>").unwrap();
let i_types = xml.find("<types>").unwrap();
assert!(i_full < i_types);
}
#[test]
fn manifest_accepts_arbitrary_string_type() {
let pkg =
PackageManifest::new("66.0").add(MetadataType::new("ExperimentalType"), ["X1", "X2"]);
let xml = pkg.to_xml();
assert!(xml.contains("<name>ExperimentalType</name>"));
assert!(xml.contains("<members>X1</members>"));
}
#[test]
fn manifest_entries_iterator_preserves_order() {
let pkg = PackageManifest::new("66.0")
.add(MetadataType::APEX_CLASS, ["Foo"])
.add(MetadataType::PROFILE, ["Admin"]);
let entries: Vec<_> = pkg.entries().collect();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].0, "ApexClass");
assert_eq!(entries[0].1, &["Foo".to_string()]);
assert_eq!(entries[1].0, "Profile");
assert_eq!(entries[1].1, &["Admin".to_string()]);
}
#[test]
fn soap_inner_uses_met_prefix() {
let pkg = PackageManifest::new("66.0").add(MetadataType::APEX_CLASS, ["Foo"]);
let inner = pkg.render_soap_inner();
assert!(inner.contains("<met:types>"));
assert!(inner.contains("<met:members>Foo</met:members>"));
assert!(inner.contains("<met:name>ApexClass</met:name>"));
assert!(inner.contains("<met:version>66.0</met:version>"));
assert!(!inner.contains("<?xml"));
assert!(!inner.contains("<Package"));
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod property_tests {
use super::*;
use proptest::prelude::*;
fn name() -> impl Strategy<Value = String> {
"[A-Za-z][A-Za-z0-9_.]{0,15}"
}
fn add_op() -> impl Strategy<Value = (String, Vec<String>)> {
(name(), proptest::collection::vec(name(), 0..4))
}
proptest! {
#[test]
fn add_groups_by_type_name(ops in proptest::collection::vec(add_op(), 0..8)) {
let mut pkg = PackageManifest::new("66.0");
let mut distinct = std::collections::BTreeSet::new();
for (ty, members) in &ops {
pkg = pkg.add(MetadataType::new(ty.clone()), members.clone());
distinct.insert(ty.clone());
}
prop_assert_eq!(
pkg.type_count(),
distinct.len(),
"type_count diverged from distinct-types set; ops={:?}",
ops,
);
}
#[test]
fn entries_preserve_first_insertion_order(
ops in proptest::collection::vec(add_op(), 0..8),
) {
let mut pkg = PackageManifest::new("66.0");
let mut expected: Vec<String> = Vec::new();
for (ty, members) in &ops {
if !expected.contains(ty) {
expected.push(ty.clone());
}
pkg = pkg.add(MetadataType::new(ty.clone()), members.clone());
}
let actual: Vec<String> = pkg.entries().map(|(t, _)| t.to_string()).collect();
prop_assert_eq!(actual, expected);
}
#[test]
fn all_overrides_add_and_is_idempotent(
ty in name(),
extra_members in proptest::collection::vec(name(), 0..4),
) {
let pkg = PackageManifest::new("66.0")
.add(MetadataType::new(ty.clone()), extra_members.clone())
.all(MetadataType::new(ty.clone()))
.all(MetadataType::new(ty.clone()));
let entries: Vec<_> = pkg.entries().collect();
prop_assert_eq!(entries.len(), 1, "expected exactly one entry after all()");
prop_assert_eq!(entries[0].0, ty.as_str());
prop_assert_eq!(entries[0].1, &["*".to_string()]);
}
#[test]
fn to_xml_always_emits_version(
api_version in "[0-9]{1,3}\\.[0-9]{1,2}",
ops in proptest::collection::vec(add_op(), 0..6),
) {
let mut pkg = PackageManifest::new(&api_version);
for (ty, members) in &ops {
pkg = pkg.add(MetadataType::new(ty.clone()), members.clone());
}
let xml = pkg.to_xml();
let expected = format!("<version>{api_version}</version>");
prop_assert!(
xml.contains(&expected),
"package.xml missing <version> tag with {api_version:?}; got:\n{xml}",
);
let mut reader = quick_xml::Reader::from_str(&xml);
loop {
match reader.read_event() {
Ok(quick_xml::events::Event::Eof) => break,
Ok(_) => {}
Err(e) => prop_assert!(false, "package.xml didn't parse: {e}; xml={xml}"),
}
}
}
}
}