1use crate::envelope::xml_escape;
57use std::borrow::Cow;
58
59#[derive(Debug, Clone, PartialEq, Eq, Hash)]
80pub struct MetadataType(Cow<'static, str>);
81
82impl MetadataType {
83 pub fn new(name: impl Into<Cow<'static, str>>) -> Self {
85 Self(name.into())
86 }
87
88 pub fn as_str(&self) -> &str {
90 &self.0
91 }
92}
93
94impl AsRef<str> for MetadataType {
95 fn as_ref(&self) -> &str {
96 &self.0
97 }
98}
99
100impl std::fmt::Display for MetadataType {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 f.write_str(&self.0)
103 }
104}
105
106impl From<&'static str> for MetadataType {
107 fn from(s: &'static str) -> Self {
108 Self(Cow::Borrowed(s))
109 }
110}
111
112impl From<String> for MetadataType {
113 fn from(s: String) -> Self {
114 Self(Cow::Owned(s))
115 }
116}
117
118impl MetadataType {
126 pub const APEX_CLASS: MetadataType = MetadataType(Cow::Borrowed("ApexClass"));
128 pub const APEX_COMPONENT: MetadataType = MetadataType(Cow::Borrowed("ApexComponent"));
129 pub const APEX_PAGE: MetadataType = MetadataType(Cow::Borrowed("ApexPage"));
130 pub const APEX_TRIGGER: MetadataType = MetadataType(Cow::Borrowed("ApexTrigger"));
131 pub const APEX_TEST_SUITE: MetadataType = MetadataType(Cow::Borrowed("ApexTestSuite"));
132
133 pub const CUSTOM_OBJECT: MetadataType = MetadataType(Cow::Borrowed("CustomObject"));
135 pub const CUSTOM_FIELD: MetadataType = MetadataType(Cow::Borrowed("CustomField"));
136 pub const CUSTOM_TAB: MetadataType = MetadataType(Cow::Borrowed("CustomTab"));
137 pub const CUSTOM_APPLICATION: MetadataType = MetadataType(Cow::Borrowed("CustomApplication"));
138 pub const CUSTOM_LABELS: MetadataType = MetadataType(Cow::Borrowed("CustomLabels"));
139 pub const CUSTOM_METADATA: MetadataType = MetadataType(Cow::Borrowed("CustomMetadata"));
140 pub const CUSTOM_OBJECT_TRANSLATION: MetadataType =
141 MetadataType(Cow::Borrowed("CustomObjectTranslation"));
142 pub const TRANSLATIONS: MetadataType = MetadataType(Cow::Borrowed("Translations"));
143 pub const STANDARD_VALUE_SET: MetadataType = MetadataType(Cow::Borrowed("StandardValueSet"));
144 pub const GLOBAL_VALUE_SET: MetadataType = MetadataType(Cow::Borrowed("GlobalValueSet"));
145 pub const RECORD_TYPE: MetadataType = MetadataType(Cow::Borrowed("RecordType"));
146 pub const LAYOUT: MetadataType = MetadataType(Cow::Borrowed("Layout"));
147 pub const LIST_VIEW: MetadataType = MetadataType(Cow::Borrowed("ListView"));
148 pub const FIELD_SET: MetadataType = MetadataType(Cow::Borrowed("FieldSet"));
149 pub const VALIDATION_RULE: MetadataType = MetadataType(Cow::Borrowed("ValidationRule"));
150 pub const WEB_LINK: MetadataType = MetadataType(Cow::Borrowed("WebLink"));
151 pub const QUICK_ACTION: MetadataType = MetadataType(Cow::Borrowed("QuickAction"));
152
153 pub const PROFILE: MetadataType = MetadataType(Cow::Borrowed("Profile"));
155 pub const PERMISSION_SET: MetadataType = MetadataType(Cow::Borrowed("PermissionSet"));
156 pub const PERMISSION_SET_GROUP: MetadataType =
157 MetadataType(Cow::Borrowed("PermissionSetGroup"));
158 pub const ROLE: MetadataType = MetadataType(Cow::Borrowed("Role"));
159 pub const GROUP: MetadataType = MetadataType(Cow::Borrowed("Group"));
160 pub const QUEUE: MetadataType = MetadataType(Cow::Borrowed("Queue"));
161 pub const SHARING_RULES: MetadataType = MetadataType(Cow::Borrowed("SharingRules"));
162
163 pub const FLOW: MetadataType = MetadataType(Cow::Borrowed("Flow"));
165 pub const FLOW_DEFINITION: MetadataType = MetadataType(Cow::Borrowed("FlowDefinition"));
166 pub const WORKFLOW: MetadataType = MetadataType(Cow::Borrowed("Workflow"));
167 pub const APPROVAL_PROCESS: MetadataType = MetadataType(Cow::Borrowed("ApprovalProcess"));
168
169 pub const REPORT: MetadataType = MetadataType(Cow::Borrowed("Report"));
171 pub const REPORT_TYPE: MetadataType = MetadataType(Cow::Borrowed("ReportType"));
172 pub const DASHBOARD: MetadataType = MetadataType(Cow::Borrowed("Dashboard"));
173 pub const DOCUMENT: MetadataType = MetadataType(Cow::Borrowed("Document"));
174 pub const EMAIL_TEMPLATE: MetadataType = MetadataType(Cow::Borrowed("EmailTemplate"));
175
176 pub const LIGHTNING_COMPONENT_BUNDLE: MetadataType =
178 MetadataType(Cow::Borrowed("LightningComponentBundle"));
179 pub const AURA_DEFINITION_BUNDLE: MetadataType =
180 MetadataType(Cow::Borrowed("AuraDefinitionBundle"));
181 pub const STATIC_RESOURCE: MetadataType = MetadataType(Cow::Borrowed("StaticResource"));
182 pub const CONTENT_ASSET: MetadataType = MetadataType(Cow::Borrowed("ContentAsset"));
183
184 pub const CONNECTED_APP: MetadataType = MetadataType(Cow::Borrowed("ConnectedApp"));
186 pub const NAMED_CREDENTIAL: MetadataType = MetadataType(Cow::Borrowed("NamedCredential"));
187 pub const AUTH_PROVIDER: MetadataType = MetadataType(Cow::Borrowed("AuthProvider"));
188 pub const REMOTE_SITE_SETTING: MetadataType = MetadataType(Cow::Borrowed("RemoteSiteSetting"));
189}
190
191#[derive(Debug, Clone)]
205pub struct PackageManifest {
206 api_version: String,
207 full_name: Option<String>,
208 entries: Vec<TypeEntry>,
209}
210
211#[derive(Debug, Clone)]
212struct TypeEntry {
213 type_name: String,
214 members: Vec<String>,
215}
216
217impl PackageManifest {
218 pub fn new(api_version: impl Into<String>) -> Self {
224 Self {
225 api_version: api_version.into(),
226 full_name: None,
227 entries: Vec::new(),
228 }
229 }
230
231 pub fn full_name(mut self, name: impl Into<String>) -> Self {
236 self.full_name = Some(name.into());
237 self
238 }
239
240 pub fn add<T, M, S>(mut self, type_name: T, members: M) -> Self
248 where
249 T: Into<MetadataType>,
250 M: IntoIterator<Item = S>,
251 S: Into<String>,
252 {
253 let type_name: MetadataType = type_name.into();
254 let new_members = members.into_iter().map(Into::into);
255 if let Some(entry) = self
256 .entries
257 .iter_mut()
258 .find(|e| e.type_name == type_name.as_str())
259 {
260 entry.members.extend(new_members);
261 } else {
262 self.entries.push(TypeEntry {
263 type_name: type_name.as_str().to_string(),
264 members: new_members.collect(),
265 });
266 }
267 self
268 }
269
270 pub fn all<T: Into<MetadataType>>(mut self, type_name: T) -> Self {
287 let type_name: MetadataType = type_name.into();
288 if let Some(entry) = self
289 .entries
290 .iter_mut()
291 .find(|e| e.type_name == type_name.as_str())
292 {
293 entry.members.clear();
294 entry.members.push("*".to_string());
295 } else {
296 self.entries.push(TypeEntry {
297 type_name: type_name.as_str().to_string(),
298 members: vec!["*".to_string()],
299 });
300 }
301 self
302 }
303
304 pub fn api_version(&self) -> &str {
306 &self.api_version
307 }
308
309 pub fn full_name_str(&self) -> Option<&str> {
311 self.full_name.as_deref()
312 }
313
314 pub fn type_count(&self) -> usize {
316 self.entries.len()
317 }
318
319 pub fn entries(&self) -> impl Iterator<Item = (&str, &[String])> {
322 self.entries
323 .iter()
324 .map(|e| (e.type_name.as_str(), e.members.as_slice()))
325 }
326
327 pub fn to_xml(&self) -> String {
335 let mut out = String::with_capacity(128 + self.entries.len() * 64);
336 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
337 out.push_str("<Package xmlns=\"http://soap.sforce.com/2006/04/metadata\">\n");
338 if let Some(full) = &self.full_name {
339 out.push_str(" <fullName>");
340 out.push_str(&xml_escape(full));
341 out.push_str("</fullName>\n");
342 }
343 for entry in &self.entries {
344 if entry.members.is_empty() {
347 continue;
348 }
349 out.push_str(" <types>\n");
350 for member in &entry.members {
351 out.push_str(" <members>");
352 out.push_str(&xml_escape(member));
353 out.push_str("</members>\n");
354 }
355 out.push_str(" <name>");
356 out.push_str(&xml_escape(&entry.type_name));
357 out.push_str("</name>\n");
358 out.push_str(" </types>\n");
359 }
360 out.push_str(" <version>");
361 out.push_str(&xml_escape(&self.api_version));
362 out.push_str("</version>\n");
363 out.push_str("</Package>\n");
364 out
365 }
366
367 pub(crate) fn render_soap_inner(&self) -> String {
375 let mut out = String::with_capacity(64 + self.entries.len() * 64);
376 if let Some(full) = &self.full_name {
377 out.push_str("<met:fullName>");
378 out.push_str(&xml_escape(full));
379 out.push_str("</met:fullName>");
380 }
381 for entry in &self.entries {
382 if entry.members.is_empty() {
383 continue;
384 }
385 out.push_str("<met:types>");
386 for member in &entry.members {
387 out.push_str("<met:members>");
388 out.push_str(&xml_escape(member));
389 out.push_str("</met:members>");
390 }
391 out.push_str("<met:name>");
392 out.push_str(&xml_escape(&entry.type_name));
393 out.push_str("</met:name>");
394 out.push_str("</met:types>");
395 }
396 out.push_str("<met:version>");
397 out.push_str(&xml_escape(&self.api_version));
398 out.push_str("</met:version>");
399 out
400 }
401}
402
403#[cfg(test)]
404#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
405mod tests {
406 use super::*;
407
408 #[test]
409 fn metadata_type_constants_round_trip() {
410 assert_eq!(MetadataType::APEX_CLASS.as_str(), "ApexClass");
411 assert_eq!(MetadataType::CUSTOM_OBJECT.as_str(), "CustomObject");
412 assert_eq!(MetadataType::PROFILE.as_str(), "Profile");
413 assert_eq!(MetadataType::FLOW.as_str(), "Flow");
414 }
415
416 #[test]
417 fn metadata_type_new_accepts_arbitrary_names() {
418 let t = MetadataType::new("FrobnozzWidget");
419 assert_eq!(t.as_str(), "FrobnozzWidget");
420 let t2 = MetadataType::new(String::from("OwnedString"));
421 assert_eq!(t2.as_str(), "OwnedString");
422 }
423
424 #[test]
425 fn metadata_type_into_from_static_str() {
426 let t: MetadataType = "MyType".into();
427 assert_eq!(t.as_str(), "MyType");
428 }
429
430 #[test]
431 fn metadata_type_implements_display() {
432 assert_eq!(MetadataType::APEX_CLASS.to_string(), "ApexClass");
433 }
434
435 #[test]
436 fn manifest_empty_emits_just_version() {
437 let pkg = PackageManifest::new("66.0");
438 let xml = pkg.to_xml();
439 assert!(xml.contains("<?xml version=\"1.0\""));
440 assert!(xml.contains("<Package xmlns=\"http://soap.sforce.com/2006/04/metadata\">"));
441 assert!(xml.contains("<version>66.0</version>"));
442 assert!(xml.contains("</Package>"));
443 assert!(!xml.contains("<types>"));
445 }
446
447 #[test]
448 fn manifest_emits_types_in_insertion_order() {
449 let pkg = PackageManifest::new("66.0")
450 .add(MetadataType::APEX_CLASS, ["Foo", "Bar"])
451 .add(MetadataType::CUSTOM_OBJECT, ["Account__c"]);
452 let xml = pkg.to_xml();
453 let i_apex = xml.find("<name>ApexClass</name>").unwrap();
454 let i_obj = xml.find("<name>CustomObject</name>").unwrap();
455 assert!(i_apex < i_obj);
456 assert!(xml.contains("<members>Foo</members>"));
457 assert!(xml.contains("<members>Bar</members>"));
458 assert!(xml.contains("<members>Account__c</members>"));
459 }
460
461 #[test]
462 fn manifest_merges_repeated_type_adds() {
463 let pkg = PackageManifest::new("66.0")
464 .add(MetadataType::APEX_CLASS, ["Foo"])
465 .add(MetadataType::CUSTOM_OBJECT, ["Acct__c"])
466 .add(MetadataType::APEX_CLASS, ["Bar", "Baz"]);
467 let xml = pkg.to_xml();
468 assert_eq!(xml.matches("<name>ApexClass</name>").count(), 1);
470 let apex_section = {
471 let start = xml.find("<types>").unwrap();
472 let end = xml[start..].find("</types>").unwrap() + start;
473 &xml[start..=end]
474 };
475 assert!(apex_section.contains("Foo"));
476 assert!(apex_section.contains("Bar"));
477 assert!(apex_section.contains("Baz"));
478 assert_eq!(pkg.type_count(), 2);
479 }
480
481 #[test]
482 fn manifest_all_emits_wildcard_member() {
483 let pkg = PackageManifest::new("66.0").all(MetadataType::CUSTOM_TAB);
484 let xml = pkg.to_xml();
485 assert!(xml.contains("<members>*</members>"));
486 assert!(xml.contains("<name>CustomTab</name>"));
487 }
488
489 #[test]
490 fn manifest_all_replaces_prior_explicit_members() {
491 let pkg = PackageManifest::new("66.0")
494 .add(MetadataType::APEX_CLASS, ["Foo", "Bar"])
495 .all(MetadataType::APEX_CLASS);
496 let xml = pkg.to_xml();
497 assert_eq!(xml.matches("<members>*</members>").count(), 1);
498 assert!(!xml.contains("<members>Foo</members>"));
499 assert!(!xml.contains("<members>Bar</members>"));
500 }
501
502 #[test]
503 fn manifest_repeated_all_collapses_to_single_wildcard() {
504 let pkg = PackageManifest::new("66.0")
505 .all(MetadataType::APEX_CLASS)
506 .all(MetadataType::APEX_CLASS);
507 let xml = pkg.to_xml();
508 assert_eq!(xml.matches("<members>*</members>").count(), 1);
509 }
510
511 #[test]
512 fn manifest_escapes_special_xml_chars_in_members() {
513 let pkg = PackageManifest::new("66.0").add(MetadataType::APEX_CLASS, ["Foo<&>"]);
514 let xml = pkg.to_xml();
515 assert!(xml.contains("<members>Foo<&></members>"));
516 assert!(!xml.contains("Foo<&>"));
517 }
518
519 #[test]
520 fn manifest_skips_types_with_empty_member_lists() {
521 let pkg = PackageManifest::new("66.0")
525 .add(MetadataType::APEX_CLASS, Vec::<String>::new())
526 .add(MetadataType::CUSTOM_OBJECT, ["Foo__c"]);
527 let xml = pkg.to_xml();
528 assert!(!xml.contains("<name>ApexClass</name>"));
529 assert!(xml.contains("<name>CustomObject</name>"));
530 }
531
532 #[test]
533 fn manifest_full_name_emitted_for_packaged_variant() {
534 let pkg = PackageManifest::new("66.0")
535 .full_name("MyManagedPackage")
536 .add(MetadataType::APEX_CLASS, ["Foo"]);
537 let xml = pkg.to_xml();
538 assert!(xml.contains("<fullName>MyManagedPackage</fullName>"));
539 let i_full = xml.find("<fullName>").unwrap();
541 let i_types = xml.find("<types>").unwrap();
542 assert!(i_full < i_types);
543 }
544
545 #[test]
546 fn manifest_accepts_arbitrary_string_type() {
547 let pkg =
549 PackageManifest::new("66.0").add(MetadataType::new("ExperimentalType"), ["X1", "X2"]);
550 let xml = pkg.to_xml();
551 assert!(xml.contains("<name>ExperimentalType</name>"));
552 assert!(xml.contains("<members>X1</members>"));
553 }
554
555 #[test]
556 fn manifest_entries_iterator_preserves_order() {
557 let pkg = PackageManifest::new("66.0")
558 .add(MetadataType::APEX_CLASS, ["Foo"])
559 .add(MetadataType::PROFILE, ["Admin"]);
560 let entries: Vec<_> = pkg.entries().collect();
561 assert_eq!(entries.len(), 2);
562 assert_eq!(entries[0].0, "ApexClass");
563 assert_eq!(entries[0].1, &["Foo".to_string()]);
564 assert_eq!(entries[1].0, "Profile");
565 assert_eq!(entries[1].1, &["Admin".to_string()]);
566 }
567
568 #[test]
569 fn soap_inner_uses_met_prefix() {
570 let pkg = PackageManifest::new("66.0").add(MetadataType::APEX_CLASS, ["Foo"]);
571 let inner = pkg.render_soap_inner();
572 assert!(inner.contains("<met:types>"));
573 assert!(inner.contains("<met:members>Foo</met:members>"));
574 assert!(inner.contains("<met:name>ApexClass</met:name>"));
575 assert!(inner.contains("<met:version>66.0</met:version>"));
576 assert!(!inner.contains("<?xml"));
578 assert!(!inner.contains("<Package"));
579 }
580}
581
582#[cfg(test)]
583#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
584mod property_tests {
585 use super::*;
586 use proptest::prelude::*;
587
588 fn name() -> impl Strategy<Value = String> {
593 "[A-Za-z][A-Za-z0-9_.]{0,15}"
594 }
595
596 fn add_op() -> impl Strategy<Value = (String, Vec<String>)> {
600 (name(), proptest::collection::vec(name(), 0..4))
601 }
602
603 proptest! {
604 #[test]
609 fn add_groups_by_type_name(ops in proptest::collection::vec(add_op(), 0..8)) {
610 let mut pkg = PackageManifest::new("66.0");
611 let mut distinct = std::collections::BTreeSet::new();
612 for (ty, members) in &ops {
613 pkg = pkg.add(MetadataType::new(ty.clone()), members.clone());
614 distinct.insert(ty.clone());
615 }
616 prop_assert_eq!(
617 pkg.type_count(),
618 distinct.len(),
619 "type_count diverged from distinct-types set; ops={:?}",
620 ops,
621 );
622 }
623
624 #[test]
629 fn entries_preserve_first_insertion_order(
630 ops in proptest::collection::vec(add_op(), 0..8),
631 ) {
632 let mut pkg = PackageManifest::new("66.0");
633 let mut expected: Vec<String> = Vec::new();
635 for (ty, members) in &ops {
636 if !expected.contains(ty) {
637 expected.push(ty.clone());
638 }
639 pkg = pkg.add(MetadataType::new(ty.clone()), members.clone());
640 }
641 let actual: Vec<String> = pkg.entries().map(|(t, _)| t.to_string()).collect();
642 prop_assert_eq!(actual, expected);
643 }
644
645 #[test]
649 fn all_overrides_add_and_is_idempotent(
650 ty in name(),
651 extra_members in proptest::collection::vec(name(), 0..4),
652 ) {
653 let pkg = PackageManifest::new("66.0")
654 .add(MetadataType::new(ty.clone()), extra_members.clone())
655 .all(MetadataType::new(ty.clone()))
656 .all(MetadataType::new(ty.clone()));
657 let entries: Vec<_> = pkg.entries().collect();
658 prop_assert_eq!(entries.len(), 1, "expected exactly one entry after all()");
659 prop_assert_eq!(entries[0].0, ty.as_str());
660 prop_assert_eq!(entries[0].1, &["*".to_string()]);
661 }
662
663 #[test]
667 fn to_xml_always_emits_version(
668 api_version in "[0-9]{1,3}\\.[0-9]{1,2}",
669 ops in proptest::collection::vec(add_op(), 0..6),
670 ) {
671 let mut pkg = PackageManifest::new(&api_version);
672 for (ty, members) in &ops {
673 pkg = pkg.add(MetadataType::new(ty.clone()), members.clone());
674 }
675 let xml = pkg.to_xml();
676 let expected = format!("<version>{api_version}</version>");
677 prop_assert!(
678 xml.contains(&expected),
679 "package.xml missing <version> tag with {api_version:?}; got:\n{xml}",
680 );
681 let mut reader = quick_xml::Reader::from_str(&xml);
684 loop {
685 match reader.read_event() {
686 Ok(quick_xml::events::Event::Eof) => break,
687 Ok(_) => {}
688 Err(e) => prop_assert!(false, "package.xml didn't parse: {e}; xml={xml}"),
689 }
690 }
691 }
692 }
693}