Add transformer module for manifest transformation logic

- Introduced `transformer.rs` with a structured approach for parsing and applying transformation rules.
- Added support for operations like `add`, `default`, `delete`, `drop`, `edit`, `emit`, and `set` on attributes, files, directories, and other targets.
- Implemented regex-based matching for patterns and backreference handling in transformations.
- Enhanced manifest modification functionality, including attribute/facet operations and deferred action emission.
- Added comprehensive unit tests to validate transformation rules and their applications.
This commit is contained in:
Till Wegmueller 2025-08-28 23:50:59 +02:00
parent 88e06b488d
commit 7cffa6c4e6
No known key found for this signature in database
6 changed files with 1257 additions and 7 deletions

View file

@ -10,9 +10,14 @@ pub mod fmri;
pub mod image;
pub mod payload;
pub mod repository;
pub mod publisher;
pub mod transformer;
pub mod solver;
mod test_json_manifest;
#[cfg(test)]
mod publisher_tests;
#[cfg(test)]
mod tests {

176
libips/src/publisher.rs Normal file
View file

@ -0,0 +1,176 @@
// This Source Code Form is subject to the terms of
// the Mozilla Public License, v. 2.0. If a copy of the
// MPL was not distributed with this file, You can
// obtain one at https://mozilla.org/MPL/2.0/.
use std::path::{Path, PathBuf};
use std::fs;
use miette::Diagnostic;
use thiserror::Error;
use crate::actions::{File as FileAction, Manifest, Transform as TransformAction};
use crate::repository::{ReadableRepository, RepositoryError, WritableRepository};
use crate::repository::file_backend::{FileBackend, Transaction};
use crate::transformer;
/// Error type for high-level publishing operations
#[derive(Debug, Error, Diagnostic)]
pub enum PublisherError {
#[error(transparent)]
#[diagnostic(transparent)]
Repository(#[from] RepositoryError),
#[error(transparent)]
#[diagnostic(transparent)]
Transform(#[from] transformer::TransformError),
#[error("I/O error: {0}")]
#[diagnostic(code(ips::publisher_error::io), help("Check the path and permissions"))]
Io(String),
#[error("invalid root path: {0}")]
#[diagnostic(code(ips::publisher_error::invalid_root_path), help("Ensure the directory exists and is readable"))]
InvalidRoot(String),
}
pub type Result<T> = std::result::Result<T, PublisherError>;
/// High-level Publisher client that keeps a repository handle and an open transaction.
///
/// This is intended to simplify software build/publish flows: instantiate once with a
/// repository path and publisher, then build/transform manifests and publish.
pub struct PublisherClient {
backend: FileBackend,
publisher: String,
tx: Option<Transaction>,
transform_rules: Vec<transformer::TransformRule>,
}
impl PublisherClient {
/// Open an existing repository located at `path` with a selected `publisher`.
pub fn open<P: AsRef<Path>>(path: P, publisher: impl Into<String>) -> Result<Self> {
let backend = FileBackend::open(path)?;
Ok(Self { backend, publisher: publisher.into(), tx: None, transform_rules: Vec::new() })
}
/// Open a transaction if not already open and return whether a new transaction was created.
pub fn open_transaction(&mut self) -> Result<bool> {
if self.tx.is_none() {
let tx = self.backend.begin_transaction()?;
self.tx = Some(tx);
return Ok(true);
}
Ok(false)
}
/// Build a new Manifest from a directory tree. Paths in the manifest are relative to `root`.
pub fn build_manifest_from_dir(&mut self, root: &Path) -> Result<Manifest> {
if !root.exists() {
return Err(PublisherError::InvalidRoot(root.display().to_string()));
}
let mut manifest = Manifest::new();
let root = root.canonicalize().map_err(|_| PublisherError::InvalidRoot(root.display().to_string()))?;
let walker = walkdir::WalkDir::new(&root).into_iter().filter_map(|e| e.ok());
// Ensure a transaction is open
if self.tx.is_none() {
self.open_transaction()?;
}
let tx = self.tx.as_mut().expect("transaction must be open");
for entry in walker {
let p = entry.path();
if p.is_file() {
// Create a File action from the absolute path
let mut f = FileAction::read_from_path(p).map_err(RepositoryError::from)?;
// Set path to be relative to root
let rel: PathBuf = p
.strip_prefix(&root)
.map_err(RepositoryError::from)?
.to_path_buf();
f.path = rel.to_string_lossy().to_string();
// Add into manifest and stage via transaction
manifest.add_file(f.clone());
tx.add_file(f, p)?;
}
}
Ok(manifest)
}
/// Make a new empty manifest
pub fn new_empty_manifest(&self) -> Manifest {
Manifest::new()
}
/// Transform a manifest with a user-supplied rule function
pub fn transform_manifest<F>(&self, mut manifest: Manifest, rule: F) -> Manifest
where
F: FnOnce(&mut Manifest),
{
rule(&mut manifest);
manifest
}
/// Add a single AST transform rule
pub fn add_transform_rule(&mut self, rule: transformer::TransformRule) {
self.transform_rules.push(rule);
}
/// Add multiple AST transform rules
pub fn add_transform_rules(&mut self, rules: Vec<transformer::TransformRule>) {
self.transform_rules.extend(rules);
}
/// Clear all configured transform rules
pub fn clear_transform_rules(&mut self) {
self.transform_rules.clear();
}
/// Load transform rules from raw text (returns number of rules added)
pub fn load_transform_rules_from_text(&mut self, text: &str) -> Result<usize> {
let rules = transformer::parse_rules_ast(text)?;
let n = rules.len();
self.transform_rules.extend(rules);
Ok(n)
}
/// Load transform rules from a file (returns number of rules added)
pub fn load_transform_rules_from_file<P: AsRef<Path>>(&mut self, path: P) -> Result<usize> {
let p = path.as_ref();
let content = fs::read_to_string(p).map_err(|e| PublisherError::Io(e.to_string()))?;
self.load_transform_rules_from_text(&content)
}
/// Publish the given manifest. If no transaction is open, one will be opened.
/// The transaction will be updated with the provided manifest and committed.
/// If `rebuild_metadata` is true, repository metadata (catalog/index) will be rebuilt.
pub fn publish(&mut self, mut manifest: Manifest, rebuild_metadata: bool) -> Result<()> {
// Apply configured transform rules (if any)
if !self.transform_rules.is_empty() {
let rules: Vec<TransformAction> = self
.transform_rules
.clone()
.into_iter()
.map(Into::into)
.collect();
transformer::apply(&mut manifest, &rules)?;
}
// Ensure transaction exists
if self.tx.is_none() {
self.open_transaction()?;
}
// Take ownership of the transaction, update and commit
let mut tx = self.tx.take().expect("transaction must be open");
tx.set_publisher(&self.publisher);
tx.update_manifest(manifest);
tx.commit()?;
// Optionally rebuild repo metadata for the publisher
if rebuild_metadata {
self.backend.rebuild(Some(&self.publisher), false, false)?;
}
Ok(())
}
}

View file

@ -0,0 +1,119 @@
// This Source Code Form is subject to the terms of
// the Mozilla Public License, v. 2.0. If a copy of the
// MPL was not distributed with this file, You can
// obtain one at https://mozilla.org/MPL/2.0/.
#[cfg(test)]
mod tests {
use std::fs;
use std::io::Write;
use tempfile::TempDir;
use crate::publisher::PublisherClient;
use crate::repository::file_backend::FileBackend;
use crate::repository::{RepositoryVersion, WritableRepository};
#[test]
fn publisher_client_basic_flow() {
// Create a temporary repository directory
let tmp = TempDir::new().expect("tempdir");
let repo_path = tmp.path().to_path_buf();
// Initialize repository
let mut backend = FileBackend::create(&repo_path, RepositoryVersion::V4).expect("create repo");
backend.add_publisher("test").expect("add publisher");
// Prepare a prototype directory with a nested file
let proto_dir = repo_path.join("proto");
let nested = proto_dir.join("nested").join("dir");
fs::create_dir_all(&nested).expect("create proto dirs");
let file_path = nested.join("hello.txt");
let content = b"Hello PublisherClient!";
let mut f = fs::File::create(&file_path).expect("create file");
f.write_all(content).expect("write content");
// Use PublisherClient to publish
let mut client = PublisherClient::open(&repo_path, "test").expect("open client");
client.open_transaction().expect("open tx");
let manifest = client.build_manifest_from_dir(&proto_dir).expect("build manifest");
client.publish(manifest, true).expect("publish");
// Verify the manifest exists at the default path for unknown version
let manifest_path = FileBackend::construct_package_dir(&repo_path, "test", "unknown").join("manifest");
assert!(manifest_path.exists(), "manifest not found at {}", manifest_path.display());
// Verify at least one file was stored under publisher/test/file
let file_root = repo_path.join("publisher").join("test").join("file");
assert!(file_root.exists(), "file store root does not exist: {}", file_root.display());
let mut any_file = false;
if let Ok(entries) = fs::read_dir(&file_root) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Ok(files) = fs::read_dir(&path) {
for f in files.flatten() {
if f.path().is_file() {
any_file = true;
break;
}
}
}
} else if path.is_file() {
any_file = true;
}
if any_file { break; }
}
}
assert!(any_file, "no stored file found in file store");
}
}
#[cfg(test)]
mod transform_rule_integration_tests {
use crate::actions::Manifest;
use crate::publisher::PublisherClient;
use crate::repository::file_backend::FileBackend;
use crate::repository::{RepositoryVersion, WritableRepository};
use std::fs;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn publisher_client_applies_transform_rules_from_file() {
// Setup repository and publisher
let tmp = TempDir::new().expect("tempdir");
let repo_path = tmp.path().to_path_buf();
let mut backend = FileBackend::create(&repo_path, RepositoryVersion::V4).expect("create repo");
backend.add_publisher("test").expect("add publisher");
// Prototype directory with a file
let proto_dir = repo_path.join("proto2");
fs::create_dir_all(&proto_dir).expect("mkdir proto2");
let file_path = proto_dir.join("foo.txt");
let mut f = fs::File::create(&file_path).expect("create file");
f.write_all(b"data").expect("write");
// Create a rules file that emits a pkg.summary attribute
let rules_path = repo_path.join("rules.txt");
let rules_text = "<transform file match_type=path pattern=.* operation=emit -> set name=pkg.summary value=\"Added via rules\">\n";
fs::write(&rules_path, rules_text).expect("write rules");
// Use PublisherClient to load rules, build manifest and publish
let mut client = PublisherClient::open(&repo_path, "test").expect("open client");
let loaded = client.load_transform_rules_from_file(&rules_path).expect("load rules");
assert!(loaded >= 1, "expected at least one rule loaded");
client.open_transaction().expect("open tx");
let manifest = client.build_manifest_from_dir(&proto_dir).expect("build manifest");
client.publish(manifest, false).expect("publish");
// Read stored manifest and verify attribute
let manifest_path = FileBackend::construct_package_dir(&repo_path, "test", "unknown").join("manifest");
assert!(manifest_path.exists(), "manifest missing: {}", manifest_path.display());
let json = fs::read_to_string(&manifest_path).expect("read manifest");
let parsed: Manifest = serde_json::from_str(&json).expect("parse manifest json");
let has_summary = parsed.attributes.iter().any(|a| a.key == "pkg.summary" && a.values.iter().any(|v| v == "Added via rules"));
assert!(has_summary, "pkg.summary attribute added via rules not found");
}
}

View file

@ -565,12 +565,17 @@ impl Transaction {
}
// Construct the manifest path using the helper method
let pkg_manifest_path = FileBackend::construct_manifest_path(
let pkg_manifest_path = if package_version.is_empty() {
// If no version was provided, store as a default manifest file
FileBackend::construct_package_dir(&self.repo, &publisher, &package_stem).join("manifest")
} else {
FileBackend::construct_manifest_path(
&self.repo,
&publisher,
&package_stem,
&package_version,
);
)
};
debug!("Manifest path: {}", pkg_manifest_path.display());
// Create parent directories if they don't exist

View file

@ -204,7 +204,7 @@ impl From<bincode::error::EncodeError> for RepositoryError {
}
}
pub mod catalog;
mod file_backend;
pub(crate) mod file_backend;
mod obsoleted;
pub mod progress;
mod rest_backend;

945
libips/src/transformer.rs Normal file
View file

@ -0,0 +1,945 @@
// This Source Code Form is subject to the terms of
// the Mozilla Public License, v. 2.0. If a copy of the
// MPL was not distributed with this file, You can
// obtain one at https://mozilla.org/MPL/2.0/.
use regex::Regex;
use std::collections::HashMap;
use miette::Diagnostic;
use thiserror::Error;
use crate::actions::{Facet, Manifest, Property, Transform};
// Programmatic AST for transform instructions
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransformTarget {
Attr,
File,
Dir,
Link,
License,
Dependency,
User,
Group,
Driver,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MatchType {
Key,
Value,
Path,
Facet,
Any,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Operation {
Add,
Default,
Delete,
Drop,
Edit,
Emit,
Set,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TransformRule {
pub target: TransformTarget,
pub match_type: MatchType,
pub pattern: Option<String>,
pub op: Operation,
pub value: Option<String>,
pub attribute: Option<String>,
pub emit_action: Option<String>,
pub extra: std::collections::HashMap<String, String>,
}
impl TransformRule {
pub fn new(target: TransformTarget, op: Operation) -> Self {
Self {
target,
match_type: MatchType::Any,
pattern: None,
op,
value: None,
attribute: None,
emit_action: None,
extra: HashMap::new(),
}
}
pub fn with_match_type(mut self, mt: MatchType) -> Self {
self.match_type = mt;
self
}
pub fn with_pattern(mut self, pat: impl Into<String>) -> Self {
self.pattern = Some(pat.into());
self
}
pub fn with_value(mut self, val: impl Into<String>) -> Self {
self.value = Some(val.into());
self
}
pub fn with_attribute(mut self, attr: impl Into<String>) -> Self {
self.attribute = Some(attr.into());
self
}
pub fn with_emit_action(mut self, act: impl Into<String>) -> Self {
self.emit_action = Some(act.into());
self
}
}
fn target_to_str(t: &TransformTarget) -> &'static str {
match t {
TransformTarget::Attr => "attr",
TransformTarget::File => "file",
TransformTarget::Dir => "dir",
TransformTarget::Link => "link",
TransformTarget::License => "license",
TransformTarget::Dependency => "dependency",
TransformTarget::User => "user",
TransformTarget::Group => "group",
TransformTarget::Driver => "driver",
}
}
fn str_to_target(s: &str) -> Option<TransformTarget> {
Some(match s {
"attr" => TransformTarget::Attr,
"file" => TransformTarget::File,
"dir" => TransformTarget::Dir,
"link" => TransformTarget::Link,
"license" => TransformTarget::License,
"dependency" => TransformTarget::Dependency,
"user" => TransformTarget::User,
"group" => TransformTarget::Group,
"driver" => TransformTarget::Driver,
_ => return None,
})
}
fn mt_to_str(mt: &MatchType) -> &'static str {
match mt {
MatchType::Key => "key",
MatchType::Value => "value",
MatchType::Path => "path",
MatchType::Facet => "facet",
MatchType::Any => "",
}
}
fn str_to_mt(s: &str) -> Option<MatchType> {
Some(match s {
"key" => MatchType::Key,
"value" => MatchType::Value,
"path" => MatchType::Path,
"facet" => MatchType::Facet,
"" => MatchType::Any,
_ => return None,
})
}
fn op_to_str(op: &Operation) -> &'static str {
match op {
Operation::Add => "add",
Operation::Default => "default",
Operation::Delete => "delete",
Operation::Drop => "drop",
Operation::Edit => "edit",
Operation::Emit => "emit",
Operation::Set => "set",
}
}
fn str_to_op(s: &str) -> Option<Operation> {
Some(match s {
"add" => Operation::Add,
"default" => Operation::Default,
"delete" => Operation::Delete,
"drop" => Operation::Drop,
"edit" => Operation::Edit,
"emit" => Operation::Emit,
"set" => Operation::Set,
_ => return None,
})
}
impl From<TransformRule> for Transform {
fn from(r: TransformRule) -> Self {
let mut t = Transform::default();
t.transform_type = target_to_str(&r.target).to_string();
t.match_type = mt_to_str(&r.match_type).to_string();
if let Some(p) = r.pattern {
t.pattern = p;
}
t.operation = op_to_str(&r.op).to_string();
if let Some(v) = r.value {
t.value = v;
}
let mut props = HashMap::new();
if let Some(a) = r.attribute {
props.insert(
"attribute".to_string(),
Property {
key: "attribute".to_string(),
value: a,
},
);
}
if let Some(e) = r.emit_action {
props.insert(
"emit_action".to_string(),
Property {
key: "emit_action".to_string(),
value: e,
},
);
}
for (k, v) in r.extra {
props.insert(k.clone(), Property { key: k, value: v });
}
t.properties = props;
t
}
}
impl std::convert::TryFrom<Transform> for TransformRule {
type Error = TransformError;
fn try_from(t: Transform) -> Result<Self> {
let target = str_to_target(&t.transform_type).ok_or_else(|| {
TransformError(format!("unknown transform_type: {}", t.transform_type))
})?;
let match_type = str_to_mt(&t.match_type).unwrap_or_else(|| {
// Default based on target when empty
match target {
TransformTarget::Attr => MatchType::Key,
TransformTarget::File | TransformTarget::Dir | TransformTarget::Link => {
MatchType::Path
}
_ => MatchType::Any,
}
});
let op = str_to_op(&t.operation)
.ok_or_else(|| TransformError(format!("unknown operation: {}", t.operation)))?;
let attribute = t.properties.get("attribute").map(|p| p.value.clone());
let emit_action = t.properties.get("emit_action").map(|p| p.value.clone());
let mut extra = HashMap::new();
for (k, p) in &t.properties {
if k != "attribute" && k != "emit_action" {
extra.insert(k.clone(), p.value.clone());
}
}
Ok(TransformRule {
target,
match_type,
pattern: if t.pattern.is_empty() {
None
} else {
Some(t.pattern)
},
op,
value: if t.value.is_empty() {
None
} else {
Some(t.value)
},
attribute,
emit_action,
extra,
})
}
}
/// Parse rules as AST
pub fn parse_rules_ast(text: &str) -> Result<Vec<TransformRule>> {
let ts = parse_rules(text)?;
let mut out = Vec::with_capacity(ts.len());
for t in ts {
out.push(TransformRule::try_from(t)?);
}
Ok(out)
}
#[derive(Debug, Error, Diagnostic)]
#[error("transformer error: {0}")]
#[diagnostic(
code(ips::transformer_error),
help("Check the transformer rules format and inputs")
)]
pub struct TransformError(String);
pub type Result<T> = std::result::Result<T, TransformError>;
/// Parse textual transform rules from a simple line-oriented format.
/// Supported syntaxes:
/// 1) Plain: `transform key=value key=value ...` where keys include:
/// - type, match_type, pattern, operation, value, attribute, emit_action
/// 2) Legacy: `<transform ACTION key=value ... -> ACTION_TEXT>`
/// We will parse key=value pairs; ACTION is mapped to `type` if not provided.
/// ACTION_TEXT is attached as `emit_action`.
pub fn parse_rules(text: &str) -> Result<Vec<Transform>> {
let mut out = Vec::new();
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.starts_with("transform ") {
out.push(parse_plain_transform_line(line)?);
} else if line.starts_with("<transform ") && line.ends_with('>') {
out.push(parse_legacy_transform_line(line)?);
} else {
// ignore unknown lines to be permissive
}
}
Ok(out)
}
/// Apply a set of transform rules onto a manifest.
///
/// Convention for Transform fields:
/// - transform_type: target kind ("attr", "file", "dir", "link", "license", "dependency")
/// - match_type: what to match within the action. Supported:
/// - "key": for attr, matches Attr.key using regex in pattern
/// - "value": for attr, matches any Attr.values using regex in pattern
/// - "path": for file/dir/link: matches path field using regex in pattern
/// - "facet": for file facet name; requires property `attribute` with facet name, pattern matches facet value
/// - pattern: regex (unanchored)
/// - operation: one of add, default, delete, drop, edit, emit, set
/// - value: operation value or replacement string
/// - properties:
/// - attribute: name of attribute/facet to operate on (for attr and facet operations)
/// - emit_action: full action line to emit when operation=="emit"
pub fn apply(manifest: &mut Manifest, rules: &[Transform]) -> Result<()> {
for rule in rules {
let re = Regex::new(&rule.pattern)
.map_err(|e| TransformError(format!("invalid regex '{}': {}", rule.pattern, e)))?;
match rule.transform_type.as_str() {
"attr" => apply_on_attrs(manifest, &re, rule)?,
"file" => apply_on_files(manifest, &re, rule)?,
"dir" => apply_on_dirs(manifest, &re, rule)?,
"link" => apply_on_links(manifest, &re, rule)?,
"license" => apply_on_licenses(manifest, &re, rule)?,
"dependency" => apply_on_dependencies(manifest, &re, rule)?,
"group" | "user" | "driver" => { /* not implemented */ }
other => return Err(TransformError(format!("unknown transform_type: {}", other))),
}
}
Ok(())
}
fn strip_quotes(s: &str) -> String {
let t = s.trim();
if (t.starts_with('"') && t.ends_with('"')) || (t.starts_with('\'') && t.ends_with('\'')) {
t[1..t.len() - 1].to_string()
} else {
t.to_string()
}
}
fn tokenize_kv(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut in_quotes = false;
let mut quote_char: char = '"';
for c in line.chars() {
match c {
'"' | '\'' => {
if in_quotes && c == quote_char {
in_quotes = false;
cur.push(c);
} else if !in_quotes {
in_quotes = true;
quote_char = c;
cur.push(c);
} else {
cur.push(c);
}
}
' ' | '\t' if !in_quotes => {
if !cur.is_empty() {
out.push(cur.clone());
cur.clear();
}
}
_ => cur.push(c),
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
fn parse_plain_transform_line(line: &str) -> Result<Transform> {
// line starts with "transform "; parse key=value tokens
let rest = line.trim_start_matches("transform ").trim();
let tokens = tokenize_kv(rest);
let mut t = Transform::default();
for tok in tokens {
if let Some(eq) = tok.find('=') {
let (k, v) = (&tok[..eq], &tok[eq + 1..]);
let key = k.trim();
let val = strip_quotes(v);
match key {
"type" => t.transform_type = val,
"match_type" => t.match_type = val,
"pattern" => t.pattern = val,
"operation" => t.operation = val,
"value" => t.value = val,
"attribute" | "emit_action" => {
t.properties.insert(
key.to_string(),
Property {
key: key.to_string(),
value: val,
},
);
}
_ => {
t.properties.insert(
key.to_string(),
Property {
key: key.to_string(),
value: val,
},
);
}
}
}
}
if t.transform_type.is_empty() {
return Err(TransformError("missing type".into()));
}
if t.operation.is_empty() {
return Err(TransformError("missing operation".into()));
}
if t.pattern.is_empty() && t.operation != "emit" {
return Err(TransformError("missing pattern".into()));
}
if t.match_type.is_empty() {
t.match_type = match t.transform_type.as_str() {
"attr" => "key".into(),
"file" | "dir" | "link" => "path".into(),
_ => "".into(),
};
}
Ok(t)
}
fn map_action_to_type(action: &str) -> String {
match action {
"set" => "attr".into(),
"file" => "file".into(),
"dir" => "dir".into(),
"hardlink" | "link" => "link".into(),
"license" => "license".into(),
"depend" => "dependency".into(),
"user" => "user".into(),
"group" => "group".into(),
"driver" => "driver".into(),
_ => action.to_string(),
}
}
fn parse_legacy_transform_line(line: &str) -> Result<Transform> {
// <transform ACTION key=value ... -> ACTION_TEXT>
let inner = line
.trim_start_matches("<transform ")
.trim_end_matches('>')
.trim();
let (left, right) = inner
.rsplit_once("->")
.ok_or_else(|| TransformError("invalid legacy transform: missing '->'".into()))?;
let action_text = right.trim();
let mut iter = left.split_whitespace();
let action = iter
.next()
.ok_or_else(|| TransformError("legacy transform missing action".into()))?;
let rest = iter.collect::<Vec<_>>().join(" ");
let mut t = Transform::default();
t.transform_type = map_action_to_type(action);
for tok in tokenize_kv(&rest) {
if let Some(eq) = tok.find('=') {
let key = &tok[..eq];
let val = strip_quotes(&tok[eq + 1..]);
match key {
"type" => t.transform_type = val,
"match_type" => t.match_type = val,
"pattern" => t.pattern = val,
"operation" => t.operation = val,
"value" => t.value = val,
_ => {
t.properties.insert(
key.to_string(),
Property {
key: key.to_string(),
value: val,
},
);
}
}
}
}
// attach emit_action if present
if !action_text.is_empty() {
t.properties.insert(
"emit_action".to_string(),
Property {
key: "emit_action".to_string(),
value: action_text.to_string(),
},
);
}
if t.match_type.is_empty() {
t.match_type = match t.transform_type.as_str() {
"attr" => "key".into(),
"file" | "dir" | "link" => "path".into(),
_ => "".into(),
};
}
Ok(t)
}
fn prop<'a>(rule: &'a Transform, key: &str) -> Option<&'a str> {
rule.properties.get(key).map(|p| p.value.as_str())
}
fn map_backrefs(s: &str) -> String {
// Convert \1, \12 style backrefs to Rust regex ${1}, ${12} style to avoid ambiguity
let mut out = String::with_capacity(s.len() + 8);
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
// Collect consecutive digits following the backslash
let mut digits = String::new();
while let Some(&d) = chars.peek() {
if d.is_ascii_digit() {
digits.push(d);
chars.next();
} else {
break;
}
}
if !digits.is_empty() {
out.push_str("${");
out.push_str(&digits);
out.push('}');
continue;
} else {
// Not a backref, keep the backslash
out.push('\\');
continue;
}
}
out.push(c);
}
out
}
fn apply_on_attrs(manifest: &mut Manifest, re: &Regex, rule: &Transform) -> Result<()> {
let attr_name = prop(rule, "attribute");
let mut to_drop: Vec<usize> = Vec::new();
let mut pending_emits: Vec<String> = Vec::new();
for (idx, a) in manifest.attributes.iter_mut().enumerate() {
let matches = match rule.match_type.as_str() {
"key" => re.is_match(&a.key),
"value" => a.values.iter().any(|v| re.is_match(v)),
_ => {
if let Some(target) = attr_name {
// match_type unspecified or custom: match attribute name equals target
a.key == target
&& (re.is_match(&a.key) || a.values.iter().any(|v| re.is_match(v)))
} else {
re.is_match(&a.key) || a.values.iter().any(|v| re.is_match(v))
}
}
};
if !matches {
continue;
}
match rule.operation.as_str() {
"add" => {
if let Some(val) = Some(rule.value.as_str()).filter(|s| !s.is_empty()) {
a.values.push(val.to_string());
}
}
"default" => {
if a.values.is_empty() {
if !rule.value.is_empty() {
a.values.push(rule.value.clone());
}
}
}
"delete" => {
// delete values matching regex (unanchored)
a.values.retain(|v| !re.is_match(v));
}
"drop" => {
to_drop.push(idx);
}
"edit" => {
let rep = map_backrefs(rule.value.as_str());
for v in &mut a.values {
let new = re.replace(v, rep.as_str()).to_string();
*v = new;
}
}
"set" => {
a.values.clear();
a.values.push(rule.value.clone());
}
"emit" => {
// defer emit until after loop to avoid nested mutable borrow
if let Some(line) = prop(rule, "emit_action") {
pending_emits.push(line.to_string());
} else {
return Err(TransformError(
"emit operation on attr requires 'emit_action' property".into(),
));
}
}
other => return Err(TransformError(format!("unknown operation: {}", other))),
}
}
// Drop in reverse order
for idx in to_drop.into_iter().rev() {
if idx < manifest.attributes.len() {
manifest.attributes.remove(idx);
}
}
// Now process deferred emits
for line in pending_emits {
emit_action_into_manifest(manifest, &line)?;
}
Ok(())
}
fn apply_on_files(manifest: &mut Manifest, re: &Regex, rule: &Transform) -> Result<()> {
let attr = prop(rule, "attribute");
let mut to_drop: Vec<usize> = Vec::new();
let mut pending_emits: Vec<String> = Vec::new();
for (idx, f) in manifest.files.iter_mut().enumerate() {
let matches = match rule.match_type.as_str() {
"path" => re.is_match(&f.path),
"facet" => {
if let Some(name) = attr {
match_facet(&f.facets, name, re)
} else {
false
}
}
_ => re.is_match(&f.path),
};
if !matches {
continue;
}
match rule.operation.as_str() {
"drop" => to_drop.push(idx),
"emit" => {
if let Some(line) = prop(rule, "emit_action") {
pending_emits.push(line.to_string());
} else {
return Err(TransformError(
"emit operation requires 'emit_action'".into(),
));
}
}
op => {
// operations on facets
if let Some(name) = attr {
apply_facet_op(&mut f.facets, name, re, op, &rule.value)?;
} else {
// fallback: edit path via set/edit (not altering stored payload); minimal implementation
match op {
"set" => {
f.path = rule.value.clone();
}
"edit" => {
let rep = map_backrefs(rule.value.as_str());
f.path = re.replace(&f.path, rep.as_str()).to_string();
}
_ => {}
}
}
}
}
}
for idx in to_drop.into_iter().rev() {
if idx < manifest.files.len() {
manifest.files.remove(idx);
}
}
// process deferred emits after mutation
for line in pending_emits {
emit_action_into_manifest(manifest, &line)?;
}
Ok(())
}
fn apply_on_dirs(manifest: &mut Manifest, re: &Regex, rule: &Transform) -> Result<()> {
// Only support drop on directories by path for now (minimal)
if rule.operation == "drop" {
manifest.directories.retain(|d| !re.is_match(&d.path));
Ok(())
} else {
Ok(())
}
}
fn apply_on_links(manifest: &mut Manifest, re: &Regex, rule: &Transform) -> Result<()> {
if rule.operation == "drop" {
manifest.links.retain(|l| !re.is_match(&l.path));
}
Ok(())
}
fn apply_on_licenses(manifest: &mut Manifest, re: &Regex, rule: &Transform) -> Result<()> {
if rule.operation == "drop" {
manifest.licenses.retain(|l| !re.is_match(&l.payload));
}
Ok(())
}
fn apply_on_dependencies(manifest: &mut Manifest, re: &Regex, rule: &Transform) -> Result<()> {
if rule.operation == "drop" {
manifest.dependencies.retain(|d| {
let fmri_str = d.fmri.as_ref().map(|f| f.to_string()).unwrap_or_default();
!re.is_match(&fmri_str)
});
}
Ok(())
}
fn match_facet(facets: &HashMap<String, Facet>, name: &str, re: &Regex) -> bool {
facets
.get(name)
.map(|f| re.is_match(&f.value))
.unwrap_or(false)
}
fn apply_facet_op(
facets: &mut HashMap<String, Facet>,
name: &str,
re: &Regex,
op: &str,
val: &str,
) -> Result<()> {
match op {
"add" => {
facets.insert(
name.to_string(),
Facet {
name: name.to_string(),
value: val.to_string(),
},
);
}
"default" => {
facets.entry(name.to_string()).or_insert(Facet {
name: name.to_string(),
value: val.to_string(),
});
}
"delete" => {
if let Some(f) = facets.get(name) {
if re.is_match(&f.value) {
facets.remove(name);
}
}
}
"edit" => {
if let Some(f) = facets.get_mut(name) {
let rep = map_backrefs(val);
let new = re.replace(&f.value, rep.as_str()).to_string();
f.value = new;
}
}
"set" => {
if let Some(f) = facets.get_mut(name) {
f.value = val.to_string();
} else {
facets.insert(
name.to_string(),
Facet {
name: name.to_string(),
value: val.to_string(),
},
);
}
}
other => {
return Err(TransformError(format!(
"unsupported facet operation: {}",
other
)));
}
}
Ok(())
}
fn emit_action_into_manifest(manifest: &mut Manifest, action_line: &str) -> Result<()> {
let m = Manifest::parse_string(format!("{}\n", action_line))
.map_err(|e| TransformError(e.to_string()))?;
// merge m into manifest
manifest.attributes.extend(m.attributes);
manifest.directories.extend(m.directories);
manifest.files.extend(m.files);
manifest.dependencies.extend(m.dependencies);
manifest.licenses.extend(m.licenses);
manifest.links.extend(m.links);
manifest.users.extend(m.users);
manifest.groups.extend(m.groups);
manifest.drivers.extend(m.drivers);
manifest.legacies.extend(m.legacies);
Ok(())
}
#[cfg(test)]
mod tests {
use crate::actions::{Attr, File};
use super::*;
#[test]
fn add_default_set_attr() {
let mut m = Manifest::new();
m.attributes.push(Attr {
key: "pkg.summary".into(),
values: vec![],
properties: Default::default(),
});
let rules = parse_rules("transform type=attr match_type=key pattern=pkg\\.summary operation=default value=Hello").unwrap();
apply(&mut m, &rules).unwrap();
assert_eq!(m.attributes[0].values, vec!["Hello".to_string()]);
let rules = parse_rules(
"transform type=attr match_type=key pattern=pkg\\.summary operation=add value=World",
)
.unwrap();
apply(&mut m, &rules).unwrap();
assert_eq!(
m.attributes[0].values,
vec!["Hello".to_string(), "World".to_string()]
);
let rules = parse_rules(
"transform type=attr match_type=key pattern=pkg\\.summary operation=set value=Only",
)
.unwrap();
apply(&mut m, &rules).unwrap();
assert_eq!(m.attributes[0].values, vec!["Only".to_string()]);
}
#[test]
fn drop_file_by_path_and_emit() {
let mut m = Manifest::new();
m.files.push(File {
path: "bin/ls".into(),
..Default::default()
});
m.files.push(File {
path: "bin/cp".into(),
..Default::default()
});
let rules = parse_rules("transform type=file match_type=path pattern=bin/ls operation=drop value=\ntransform type=file match_type=path pattern=bin/cp operation=emit value= attribute=ignored emit_action=\"set name=pkg.summary value=added\"").unwrap();
apply(&mut m, &rules).unwrap();
assert_eq!(m.files.len(), 1);
assert_eq!(m.attributes.len(), 1);
}
#[test]
fn edit_file_facet() {
let mut m = Manifest::new();
let mut f = File {
path: "usr/bin/foo".into(),
..Default::default()
};
f.facets.insert(
"variant.arch".into(),
Facet {
name: "variant.arch".into(),
value: "i386".into(),
},
);
m.files.push(f);
let rules = parse_rules("transform type=file match_type=facet pattern=i386 operation=edit value=amd64 attribute=variant.arch").unwrap();
apply(&mut m, &rules).unwrap();
assert_eq!(m.files[0].facets["variant.arch"].value, "amd64");
}
#[test]
fn backrefs_in_attr_edit() {
// Set an attribute with a value like "name-123" and use two capture groups
let mut m = Manifest::new();
m.attributes.push(Attr {
key: "some.attr".into(),
values: vec!["abc-123".into()],
properties: Default::default(),
});
let rules = parse_rules("transform type=attr match_type=value pattern=\"([a-z]+)-(\\d+)\" operation=edit value=\"\\1_\\2\"").unwrap();
apply(&mut m, &rules).unwrap();
assert_eq!(m.attributes[0].values[0], "abc_123");
}
#[test]
fn parse_rules_ast_plain() {
let rules = parse_rules_ast(
"transform type=attr match_type=key pattern=pkg\\.summary operation=set value=Hello",
)
.unwrap();
assert_eq!(rules.len(), 1);
let r = &rules[0];
match r.target {
TransformTarget::Attr => {}
_ => panic!("wrong target"),
}
match r.match_type {
MatchType::Key => {}
_ => panic!("wrong match type"),
}
assert_eq!(r.pattern.as_deref(), Some("pkg\\.summary"));
match r.op {
Operation::Set => {}
_ => panic!("wrong op"),
}
assert_eq!(r.value.as_deref(), Some("Hello"));
}
#[test]
fn programmatic_rule_apply_edit_with_backrefs() {
// Prepare manifest
let mut m = Manifest::new();
m.attributes.push(Attr {
key: "some.attr".into(),
values: vec!["foo-123".into()],
properties: Default::default(),
});
// Build TransformRule programmatically
let rule = TransformRule::new(TransformTarget::Attr, Operation::Edit)
.with_match_type(MatchType::Value)
.with_pattern("([a-z]+)-(\\d+)")
.with_value("\\1_\\2");
// Convert to existing Transform and apply
let t: Transform = rule.clone().into();
apply(&mut m, &[t]).unwrap();
assert_eq!(m.attributes[0].values[0], "foo_123");
// Round-trip conversion back to AST
let t2: Transform = rule.clone().into();
let r2 = TransformRule::try_from(t2).unwrap();
// target/op remain the same
match r2.target {
TransformTarget::Attr => {}
_ => panic!("target changed"),
}
match r2.op {
Operation::Edit => {}
_ => panic!("op changed"),
}
}
}