Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ xml-disassembler parse <path>
| `--ignore-path <path>` | Path to the ignore file | .xmldisassemblerignore |
| `--format <fmt>` | Output format: xml, json, json5, yaml | xml |
| `--strategy <name>` | unique-id or grouped-by-tag | unique-id |
| `-p`, `--split-tags <spec>` | With grouped-by-tag: split or group nested tags into subdirs (e.g. `objectPermissions:split:object,fieldPermissions:group:field`) | (none) |
| `--multi-level <spec>` | Further disassemble matching files: `file_pattern:root_to_strip:unique_id_elements` | (none) |

#### Reassemble options
Expand Down Expand Up @@ -170,6 +171,21 @@ xml-disassembler disassemble ./my.xml --strategy grouped-by-tag --format yaml

Reassembly preserves element content and structure.

#### Split tags (`-p` / `--split-tags`)

With `--strategy grouped-by-tag`, you can optionally **split** or **group** specific nested tags into subdirectories instead of a single file per tag. Useful for permission sets and similar metadata: e.g. one file per `objectPermissions` under `objectPermissions/`, and `fieldPermissions` grouped by object under `fieldPermissions/`.

Spec: comma-separated rules. Each rule is `tag:mode:field` or `tag:path:mode:field` (path defaults to tag). **mode** is `split` (one file per array item, filename from `field`) or `group` (group array items by `field`, one file per group).

```bash
# Permission set: objectPermissions → one file per object; fieldPermissions → one file per field value
xml-disassembler disassemble fixtures/split-tags/HR_Admin.permissionset-meta.xml \
--strategy grouped-by-tag \
-p "objectPermissions:split:object,fieldPermissions:group:field"
```

Creates `HR_Admin/` with e.g. `objectPermissions/Job_Request__c.objectPermissions-meta.xml`, `objectPermissions/Account.objectPermissions-meta.xml`, `fieldPermissions/<fieldValue>.fieldPermissions-meta.xml`, plus the main `HR_Admin.permissionset-meta.xml` with the rest. Reassembly requires no changes: the existing reassemble command merges subdirs and files back into one XML.

### Multi-level disassembly

For advanced use cases (e.g. Salesforce Loyalty Program Setup metadata), you can further disassemble specific output files by stripping a root element and re-running disassembly with different unique-id elements.
Expand Down
68 changes: 68 additions & 0 deletions fixtures/split-tags/HR_Admin.permissionset-meta.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<PermissionSet xmlns="http://soap.sforce.com/2006/04/metadata">
<applicationVisibilities>
<application>JobApps__Recruiting</application>
<visible>true</visible>
</applicationVisibilities>
<classAccesses>
<apexClass>Send_Email_Confirmation</apexClass>
<enabled>true</enabled>
</classAccesses>
<fieldPermissions>
<editable>true</editable>
<field>Account.Name</field>
<readable>true</readable>
</fieldPermissions>
<fieldPermissions>
<editable>true</editable>
<field>Account.Type</field>
<readable>true</readable>
</fieldPermissions>
<fieldPermissions>
<editable>true</editable>
<field>Job_Request__c.SalaryPay__c</field>
<readable>true</readable>
</fieldPermissions>
<fieldPermissions>
<editable>true</editable>
<field>Job_Request__c.Salary__c</field>
<readable>true</readable>
</fieldPermissions>
<description>Grants all rights needed for an HR administrator to manage employees.</description>
<label>HR Administration</label>
<userLicense>Salesforce</userLicense>
<objectPermissions>
<allowCreate>true</allowCreate>
<allowDelete>true</allowDelete>
<allowEdit>false</allowEdit>
<allowRead>true</allowRead>
<viewAllRecords>true</viewAllRecords>
<modifyAllRecords>false</modifyAllRecords>
<object>Account</object>
</objectPermissions>
<objectPermissions>
<allowCreate>true</allowCreate>
<allowDelete>true</allowDelete>
<allowEdit>true</allowEdit>
<allowRead>true</allowRead>
<viewAllRecords>true</viewAllRecords>
<modifyAllRecords>true</modifyAllRecords>
<object>Job_Request__c</object>
</objectPermissions>
<pageAccesses>
<apexPage>Job_Request_Web_Form</apexPage>
<enabled>true</enabled>
</pageAccesses>
<recordTypeVisibilities>
<recordType>Recruiting.DevManager</recordType>
<visible>true</visible>
</recordTypeVisibilities>
<tabSettings>
<tab>Job_Request__c</tab>
<visibility>Available</visibility>
</tabSettings>
<userPermissions>
<enabled>true</enabled>
<name>APIEnabled</name>
</userPermissions>
</PermissionSet>
151 changes: 136 additions & 15 deletions src/builders/build_disassembled_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ use crate::builders::{build_disassembled_file, extract_root_attributes};
use crate::parsers::{
extract_xml_declaration_from_raw, extract_xmlns_from_raw, parse_element_unified,
};
use crate::types::{BuildDisassembledFilesOptions, XmlElementArrayMap, XmlElementParams};
use crate::types::{
BuildDisassembledFilesOptions, DecomposeRule, XmlElementArrayMap, XmlElementParams,
};
use serde_json::{Map, Value};
use std::collections::HashMap;
use tokio::fs;

const BATCH_SIZE: usize = 20;
Expand Down Expand Up @@ -108,6 +111,39 @@ async fn disassemble_element_keys(
(leaf_content, nested_groups, leaf_count, has_nested_elements)
}

/// Extract string from an element's field - handles direct strings and objects with #text (XML leaf elements).
fn get_field_value(element: &Value, field: &str) -> Option<String> {
let obj = element.as_object()?;
let v = obj.get(field)?;
if let Some(s) = v.as_str() {
return Some(s.to_string());
}
if let Some(child) = v.as_object() {
if let Some(text) = child.get("#text").and_then(|t| t.as_str()) {
return Some(text.to_string());
}
}
None
}

/// For group mode: use the segment before the first '.' as key when present (e.g. "Account.Name" -> "Account").
fn group_key_from_field_value(s: &str) -> &str {
s.find('.').map(|i| &s[..i]).unwrap_or(s)
}

/// Sanitize a string for use as a filename (no path separators or invalid chars).
fn sanitize_filename(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.collect()
}

async fn write_nested_groups(
nested_groups: &XmlElementArrayMap,
strategy: &str,
Expand All @@ -116,30 +152,113 @@ async fn write_nested_groups(
if strategy != "grouped-by-tag" {
return;
}
let decompose_by_tag: HashMap<&str, &DecomposeRule> = options
.decompose_rules
.map(|rules| rules.iter().map(|r| (r.tag.as_str(), r)).collect())
.unwrap_or_default();

for (tag, arr) in nested_groups {
let _ = build_disassembled_file(crate::types::BuildDisassembledFileOptions {
content: Value::Array(arr.clone()),
disassembled_path: options.disassembled_path,
output_file_name: Some(&format!("{}.{}", tag, options.format)),
subdirectory: None,
wrap_key: Some(tag),
is_grouped_array: true,
root_element_name: options.root_element_name,
root_attributes: options.root_attributes.clone(),
format: options.format,
xml_declaration: options.xml_declaration.clone(),
unique_id_elements: None,
})
.await;
let rule = decompose_by_tag.get(tag.as_str());
let path_segment = rule
.map(|r| {
if r.path_segment.is_empty() {
&r.tag
} else {
&r.path_segment
}
})
.unwrap_or(tag);

if let Some(r) = rule {
if r.mode == "split" {
for (idx, item) in arr.iter().enumerate() {
let name = get_field_value(item, &r.field)
.as_deref()
.map(sanitize_filename)
.filter(|s: &String| !s.is_empty())
.unwrap_or_else(|| idx.to_string());
let file_name = format!("{}.{}-meta.{}", name, tag, options.format);
let _ = build_disassembled_file(crate::types::BuildDisassembledFileOptions {
content: item.clone(),
disassembled_path: options.disassembled_path,
output_file_name: Some(&file_name),
subdirectory: Some(path_segment),
wrap_key: Some(tag),
is_grouped_array: false,
root_element_name: options.root_element_name,
root_attributes: options.root_attributes.clone(),
format: options.format,
xml_declaration: options.xml_declaration.clone(),
unique_id_elements: None,
})
.await;
}
} else if r.mode == "group" {
let mut by_key: HashMap<String, Vec<Value>> = HashMap::new();
for item in arr {
let key = get_field_value(item, &r.field)
.as_deref()
.map(group_key_from_field_value)
.map(sanitize_filename)
.filter(|s: &String| !s.is_empty())
.unwrap_or_else(|| "unknown".to_string());
by_key.entry(key).or_default().push(item.clone());
}
for (key, group) in by_key {
let file_name = format!("{}.{}-meta.{}", key, tag, options.format);
let _ = build_disassembled_file(crate::types::BuildDisassembledFileOptions {
content: Value::Array(group),
disassembled_path: options.disassembled_path,
output_file_name: Some(&file_name),
subdirectory: Some(path_segment),
wrap_key: Some(tag),
is_grouped_array: true,
root_element_name: options.root_element_name,
root_attributes: options.root_attributes.clone(),
format: options.format,
xml_declaration: options.xml_declaration.clone(),
unique_id_elements: None,
})
.await;
}
} else {
fallback_write_one_file(tag, arr, path_segment, options).await;
}
} else {
fallback_write_one_file(tag, arr, path_segment, options).await;
}
}
}

async fn fallback_write_one_file(
tag: &str,
arr: &[Value],
_path_segment: &str,
options: &WriteNestedOptions<'_>,
) {
let _ = build_disassembled_file(crate::types::BuildDisassembledFileOptions {
content: Value::Array(arr.to_vec()),
disassembled_path: options.disassembled_path,
output_file_name: Some(&format!("{}.{}", tag, options.format)),
subdirectory: None,
wrap_key: Some(tag),
is_grouped_array: true,
root_element_name: options.root_element_name,
root_attributes: options.root_attributes.clone(),
format: options.format,
xml_declaration: options.xml_declaration.clone(),
unique_id_elements: None,
})
.await;
}

struct WriteNestedOptions<'a> {
disassembled_path: &'a str,
root_element_name: &'a str,
root_attributes: Value,
xml_declaration: Option<Value>,
format: &'a str,
decompose_rules: Option<&'a [DecomposeRule]>,
}

pub async fn build_disassembled_files_unified(
Expand All @@ -153,6 +272,7 @@ pub async fn build_disassembled_files_unified(
format,
unique_id_elements,
strategy,
decompose_rules,
} = options;

let xml_content = match fs::read_to_string(file_path).await {
Expand Down Expand Up @@ -215,6 +335,7 @@ pub async fn build_disassembled_files_unified(
root_attributes: root_attributes.clone(),
xml_declaration: xml_declaration.clone(),
format,
decompose_rules,
};
write_nested_groups(&nested_groups, strategy, &write_opts).await;

Expand Down
12 changes: 11 additions & 1 deletion src/handlers/disassemble.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use crate::multi_level::{
strip_root_and_build_xml,
};
use crate::parsers::parse_xml;
use crate::types::{BuildDisassembledFilesOptions, MultiLevelRule};
use crate::types::{BuildDisassembledFilesOptions, DecomposeRule, MultiLevelRule};
use ignore::gitignore::GitignoreBuilder;
use std::path::Path;
use tokio::fs;
Expand Down Expand Up @@ -62,6 +62,7 @@ impl DisassembleXmlFileHandler {
ignore_path: &str,
format: &str,
multi_level_rule: Option<&MultiLevelRule>,
decompose_rules: Option<&[DecomposeRule]>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let strategy = strategy.unwrap_or("unique-id");
let strategy = if ["unique-id", "grouped-by-tag"].contains(&strategy) {
Expand Down Expand Up @@ -92,6 +93,7 @@ impl DisassembleXmlFileHandler {
post_purge,
format,
multi_level_rule,
decompose_rules,
)
.await?;
} else if meta.is_dir() {
Expand All @@ -103,6 +105,7 @@ impl DisassembleXmlFileHandler {
post_purge,
format,
multi_level_rule,
decompose_rules,
)
.await?;
}
Expand All @@ -121,6 +124,7 @@ impl DisassembleXmlFileHandler {
post_purge: bool,
format: &str,
multi_level_rule: Option<&MultiLevelRule>,
decompose_rules: Option<&[DecomposeRule]>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let resolved = Path::new(file_path)
.canonicalize()
Expand Down Expand Up @@ -150,6 +154,7 @@ impl DisassembleXmlFileHandler {
post_purge,
format,
multi_level_rule,
decompose_rules,
)
.await
}
Expand All @@ -164,6 +169,7 @@ impl DisassembleXmlFileHandler {
post_purge: bool,
format: &str,
multi_level_rule: Option<&MultiLevelRule>,
decompose_rules: Option<&[DecomposeRule]>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut entries = fs::read_dir(dir_path).await?;
let cwd = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
Expand All @@ -190,6 +196,7 @@ impl DisassembleXmlFileHandler {
post_purge,
format,
multi_level_rule,
decompose_rules,
)
.await?;
}
Expand All @@ -209,6 +216,7 @@ impl DisassembleXmlFileHandler {
post_purge: bool,
format: &str,
multi_level_rule: Option<&MultiLevelRule>,
decompose_rules: Option<&[DecomposeRule]>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
log::debug!("Parsing file to disassemble: {}", file_path);

Expand All @@ -231,6 +239,7 @@ impl DisassembleXmlFileHandler {
format,
unique_id_elements,
strategy,
decompose_rules,
})
.await?;

Expand Down Expand Up @@ -323,6 +332,7 @@ impl DisassembleXmlFileHandler {
format,
unique_id_elements: Some(&rule.unique_id_elements),
strategy: "unique-id",
decompose_rules: None,
})
.await?;

Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@ pub use multi_level::{
};
pub use parsers::parse_xml;
pub use transformers::{transform_to_json, transform_to_json5, transform_to_yaml};
pub use types::{MultiLevelConfig, MultiLevelRule, XmlElement};
pub use types::{DecomposeRule, MultiLevelConfig, MultiLevelRule, XmlElement};
Loading