Skip to main content

cirrus_metadata/
package_manifest.rs

1//! Typed builder for `package.xml` manifests.
2//!
3//! `package.xml` is the platform-defined manifest format for the
4//! Metadata API — it tells deploy and retrieve calls which metadata
5//! components to act on. The shape is fixed (the same XML is consumed
6//! by `deploy()` inside a zip and by `retrieve()` as the `unpackaged`
7//! SOAP parameter), so we model it as a typed builder rather than
8//! asking callers to hand-render the XML.
9//!
10//! ## Quick start
11//!
12//! ```
13//! use cirrus_metadata::{MetadataType, PackageManifest};
14//!
15//! let pkg = PackageManifest::new("66.0")
16//!     .add(MetadataType::APEX_CLASS, ["Foo", "Bar"])
17//!     .add(MetadataType::CUSTOM_OBJECT, ["Account__c"])
18//!     .all(MetadataType::CUSTOM_TAB);
19//!
20//! let xml: String = pkg.to_xml();
21//! assert!(xml.contains("<members>Foo</members>"));
22//! assert!(xml.contains("<members>*</members>"));
23//! ```
24//!
25//! ## Wire shape
26//!
27//! The emitted XML is the canonical `package.xml`:
28//!
29//! ```xml
30//! <?xml version="1.0" encoding="UTF-8"?>
31//! <Package xmlns="http://soap.sforce.com/2006/04/metadata">
32//!     <types>
33//!         <members>Foo</members>
34//!         <members>Bar</members>
35//!         <name>ApexClass</name>
36//!     </types>
37//!     <version>66.0</version>
38//! </Package>
39//! ```
40//!
41//! For the SOAP `retrieve()` `unpackaged` parameter, `cirrus-metadata`
42//! emits the same structure with the `met:` namespace prefix internally —
43//! callers don't need a different builder. Just pass the same
44//! `PackageManifest` to [`RetrieveRequest::unpackaged`].
45//!
46//! ## `MetadataType` registry coverage
47//!
48//! Constants on [`MetadataType`] cover the ~40 most-commonly-used
49//! types. For anything not enumerated, use [`MetadataType::new`] with
50//! the Salesforce-defined name — the `xml_name` field returned by
51//! [`MetadataClient::describe_metadata`] is the authoritative source.
52//!
53//! [`RetrieveRequest::unpackaged`]: crate::RetrieveRequest::unpackaged
54//! [`MetadataClient::describe_metadata`]: crate::MetadataClient::describe_metadata
55
56use crate::envelope::xml_escape;
57use std::borrow::Cow;
58
59/// Identifier for a metadata type — the `xml_name` value that goes in
60/// `<types><name>` and as the SOAP type parameter.
61///
62/// Use the associated constants ([`Self::APEX_CLASS`], etc.) for
63/// common types, or [`Self::new`] for any Salesforce-defined type
64/// that isn't covered by a constant. The constants are
65/// `const`-constructible, so they cost nothing at runtime — they're
66/// just typed wrappers around `&'static str` values.
67///
68/// ```
69/// use cirrus_metadata::MetadataType;
70///
71/// // Use a constant for a common type:
72/// let t = MetadataType::APEX_CLASS;
73/// assert_eq!(t.as_str(), "ApexClass");
74///
75/// // Or pass an arbitrary type name:
76/// let t = MetadataType::new("MyCustomFeatureType");
77/// assert_eq!(t.as_str(), "MyCustomFeatureType");
78/// ```
79#[derive(Debug, Clone, PartialEq, Eq, Hash)]
80pub struct MetadataType(Cow<'static, str>);
81
82impl MetadataType {
83    /// Construct from any `&'static str` or `String`.
84    pub fn new(name: impl Into<Cow<'static, str>>) -> Self {
85        Self(name.into())
86    }
87
88    /// The bare name as it appears in `package.xml`.
89    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
118// Common type constants. This is a curated subset, not exhaustive —
119// Salesforce ships ~200 metadata types and the list grows every
120// release. For anything not listed here, use [`MetadataType::new`].
121//
122// Constants are grouped by area (Apex, customization, security,
123// reporting, Lightning) and use SCREAMING_SNAKE_CASE per Rust
124// convention.
125impl MetadataType {
126    // -- Apex --
127    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    // -- Customization --
134    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    // -- Security --
154    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    // -- Process automation --
164    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    // -- Reporting --
170    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    // -- Lightning / static assets --
177    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    // -- Integration / connected apps --
185    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// -- Manifest ---------------------------------------------------------------
192
193/// Builder for `package.xml` and the SOAP `unpackaged` retrieve
194/// parameter.
195///
196/// Constructed via [`Self::new`], then chained: [`Self::add`] for
197/// explicit member lists, [`Self::all`] for a `*` wildcard,
198/// [`Self::full_name`] for managed-package manifests.
199///
200/// Insertion order is preserved — entries appear in the emitted XML
201/// in the order they were added. Members within a type also preserve
202/// caller order, with no de-duplication. Adding the same metadata
203/// type more than once merges the member lists in order.
204#[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    /// Create a new empty manifest at the given API version.
219    ///
220    /// `api_version` is the Salesforce version string (e.g. `"66.0"`)
221    /// — emitted into `<version>` in `package.xml` and into the
222    /// `<apiVersion>` field on SOAP retrieve.
223    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    /// Set a `<fullName>` element — only meaningful for first- and
232    /// second-generation packaged manifests (i.e. you're deploying a
233    /// named managed-package). Omit for the much more common
234    /// unpackaged case.
235    pub fn full_name(mut self, name: impl Into<String>) -> Self {
236        self.full_name = Some(name.into());
237        self
238    }
239
240    /// Add components of one metadata type. If the type has already
241    /// been added, the new members are appended to its existing list.
242    ///
243    /// Each member name is the metadata component's `fullName` —
244    /// `"Foo"` for `MetadataType::APEX_CLASS`, `"Account__c"` for
245    /// `MetadataType::CUSTOM_OBJECT`, `"Account.MyField__c"` for
246    /// `MetadataType::CUSTOM_FIELD`, etc.
247    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    /// Add a metadata type with the `*` wildcard member, retrieving
271    /// every component of that type.
272    ///
273    /// If the type already has explicit members from a prior
274    /// [`Self::add`], they are dropped — Salesforce rejects a `<types>`
275    /// block that mixes `*` with explicit members, and the intent of
276    /// `all` ("everything of this type") subsumes any earlier list.
277    /// Repeated `all` calls for the same type collapse to a single
278    /// `"*"` entry.
279    ///
280    /// Not all metadata types support wildcards — types like `Profile`
281    /// or `Settings` require explicit `fullName` values. Salesforce's
282    /// `describeMetadata` response lists which types are
283    /// wildcardable; this builder doesn't validate, so a wildcard on
284    /// a non-supporting type will surface as a server-side error at
285    /// deploy/retrieve time.
286    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    /// Returns the API version this manifest targets.
305    pub fn api_version(&self) -> &str {
306        &self.api_version
307    }
308
309    /// Returns the optional `<fullName>` for packaged manifests.
310    pub fn full_name_str(&self) -> Option<&str> {
311        self.full_name.as_deref()
312    }
313
314    /// Number of distinct metadata types in this manifest.
315    pub fn type_count(&self) -> usize {
316        self.entries.len()
317    }
318
319    /// Iterate over the distinct `(type_name, members)` pairs in
320    /// insertion order.
321    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    /// Render as `package.xml` file content.
328    ///
329    /// The output is the canonical Salesforce form — XML declaration,
330    /// `<Package>` element with the metadata namespace as default,
331    /// one `<types>` block per metadata type with `<members>` before
332    /// `<name>`, and a final `<version>`. Suitable for inclusion in a
333    /// deploy zip.
334    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            // Empty member lists are dropped — emitting <types> with
345            // no <members> is invalid per Salesforce's schema.
346            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    /// Render the inner XML for a SOAP `<met:unpackaged>` element.
368    ///
369    /// Used internally by the retrieve handler — emits the same
370    /// content as [`Self::to_xml`] but with the `met:` namespace
371    /// prefix on every element and without the `<Package>` wrapper
372    /// or XML declaration. Not exposed publicly because the only
373    /// supported use is inside a SOAP retrieve envelope.
374    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        // No <types> blocks for an empty manifest.
444        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        // ApexClass should appear once with all three members.
469        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        // Mixing `*` with explicit names is rejected server-side; .all
492        // should subsume the prior list rather than produce ["Foo", "*"].
493        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&lt;&amp;&gt;</members>"));
516        assert!(!xml.contains("Foo<&>"));
517    }
518
519    #[test]
520    fn manifest_skips_types_with_empty_member_lists() {
521        // Edge case: add() with empty iterator. Emitting <types> with
522        // no <members> is invalid per Salesforce's schema; skip
523        // silently instead.
524        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        // <fullName> must come before <types> per the WSDL.
540        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        // Caller can use a type not in the constants list.
548        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        // No XML declaration, no <Package> wrapper.
577        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    /// Names we'd realistically see as Salesforce metadata type or
589    /// member identifiers — leading letter, then alphanumerics +
590    /// underscore + dot (for the `Object.Field` syntax used by
591    /// `CustomField`). 1-16 chars keeps shrinking fast.
592    fn name() -> impl Strategy<Value = String> {
593        "[A-Za-z][A-Za-z0-9_.]{0,15}"
594    }
595
596    /// One `(type, members)` add() input. Members may be empty —
597    /// `add(t, [])` is legal and historically tested as a no-op for
598    /// rendering but should still count toward the type list.
599    fn add_op() -> impl Strategy<Value = (String, Vec<String>)> {
600        (name(), proptest::collection::vec(name(), 0..4))
601    }
602
603    proptest! {
604        /// For any sequence of `add(t, m)` calls, the number of
605        /// distinct types added equals `type_count()`. Two `add`s
606        /// with the same type *don't* produce two entries — the
607        /// merge-on-existing behavior is the documented contract.
608        #[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        /// `add` preserves insertion order on first-seen types.
625        /// Re-adding an existing type doesn't reorder the entry list
626        /// (the merge happens in-place at the existing entry's slot).
627        /// This is the contract `entries()` advertises.
628        #[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            // Build the expected order: first occurrence of each type, in op order.
634            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        /// `all(t)` collapses any prior `add(t, _)` entries to the
646        /// single `"*"` wildcard member. Repeated `all(t)` is also
647        /// idempotent.
648        #[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        /// `to_xml()` always emits `<version>` regardless of the
664        /// add sequence. The version is the only field Salesforce
665        /// requires unconditionally in `package.xml`.
666        #[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            // Sanity: well-formed enough that quick-xml can stream it
682            // without erroring.
683            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}