From 7cffa6c4e6b1a0f51006922cc9d7b7a88ed90683 Mon Sep 17 00:00:00 2001 From: Till Wegmueller Date: Thu, 28 Aug 2025 23:50:59 +0200 Subject: [PATCH] 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. --- libips/src/lib.rs | 5 + libips/src/publisher.rs | 176 +++++ libips/src/publisher_tests.rs | 119 ++++ libips/src/repository/file_backend.rs | 17 +- libips/src/repository/mod.rs | 2 +- libips/src/transformer.rs | 945 ++++++++++++++++++++++++++ 6 files changed, 1257 insertions(+), 7 deletions(-) create mode 100644 libips/src/publisher.rs create mode 100644 libips/src/publisher_tests.rs create mode 100644 libips/src/transformer.rs diff --git a/libips/src/lib.rs b/libips/src/lib.rs index 8dcba9f..85d6090 100644 --- a/libips/src/lib.rs +++ b/libips/src/lib.rs @@ -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 { diff --git a/libips/src/publisher.rs b/libips/src/publisher.rs new file mode 100644 index 0000000..b442719 --- /dev/null +++ b/libips/src/publisher.rs @@ -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 = std::result::Result; + +/// 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, + transform_rules: Vec, +} + +impl PublisherClient { + /// Open an existing repository located at `path` with a selected `publisher`. + pub fn open>(path: P, publisher: impl Into) -> Result { + 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 { + 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 { + 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(&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) { + 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 { + 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>(&mut self, path: P) -> Result { + 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 = 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(()) + } +} diff --git a/libips/src/publisher_tests.rs b/libips/src/publisher_tests.rs new file mode 100644 index 0000000..3040e3c --- /dev/null +++ b/libips/src/publisher_tests.rs @@ -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 = " 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"); + } +} diff --git a/libips/src/repository/file_backend.rs b/libips/src/repository/file_backend.rs index 903d9eb..a8966a1 100644 --- a/libips/src/repository/file_backend.rs +++ b/libips/src/repository/file_backend.rs @@ -565,12 +565,17 @@ impl Transaction { } // Construct the manifest path using the helper method - let pkg_manifest_path = FileBackend::construct_manifest_path( - &self.repo, - &publisher, - &package_stem, - &package_version, - ); + 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 diff --git a/libips/src/repository/mod.rs b/libips/src/repository/mod.rs index 3b74153..fcac099 100644 --- a/libips/src/repository/mod.rs +++ b/libips/src/repository/mod.rs @@ -204,7 +204,7 @@ impl From for RepositoryError { } } pub mod catalog; -mod file_backend; +pub(crate) mod file_backend; mod obsoleted; pub mod progress; mod rest_backend; diff --git a/libips/src/transformer.rs b/libips/src/transformer.rs new file mode 100644 index 0000000..8d05aad --- /dev/null +++ b/libips/src/transformer.rs @@ -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, + pub op: Operation, + pub value: Option, + pub attribute: Option, + pub emit_action: Option, + pub extra: std::collections::HashMap, +} + +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) -> Self { + self.pattern = Some(pat.into()); + self + } + pub fn with_value(mut self, val: impl Into) -> Self { + self.value = Some(val.into()); + self + } + pub fn with_attribute(mut self, attr: impl Into) -> Self { + self.attribute = Some(attr.into()); + self + } + pub fn with_emit_action(mut self, act: impl Into) -> 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 { + 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 { + 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 { + 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 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 for TransformRule { + type Error = TransformError; + fn try_from(t: Transform) -> Result { + 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> { + 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 = std::result::Result; + +/// 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: ` 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> { + 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("') { + 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 { + 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 { + // 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 { + // ACTION_TEXT> + let inner = line + .trim_start_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::>().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 = Vec::new(); + let mut pending_emits: Vec = 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 = Vec::new(); + let mut pending_emits: Vec = 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, 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, + 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"), + } + } +}