use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum PathFormat {
#[serde(alias = "posix", alias = "Posix")]
Posix,
#[serde(alias = "windows", alias = "Windows")]
Windows,
#[serde(alias = "URI", alias = "uri", alias = "Uri")]
Uri,
}
impl PathFormat {
pub fn host() -> Self {
if cfg!(windows) {
PathFormat::Windows
} else {
PathFormat::Posix
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PathMappingRule {
pub source_path_format: PathFormat,
pub source_path: String,
pub destination_path: String,
}
impl PathMappingRule {
pub fn apply(&self, path: &str) -> Option<String> {
self.apply_with_format(path, PathFormat::host())
}
pub fn apply_with_format(&self, path: &str, output_format: PathFormat) -> Option<String> {
let sep = match output_format {
PathFormat::Windows => '\\',
_ => '/',
};
match self.source_path_format {
PathFormat::Uri => self.apply_uri(path, sep),
PathFormat::Posix => self.apply_filesystem(path, false, sep),
PathFormat::Windows => self.apply_filesystem(path, true, sep),
}
}
fn uri_path_start(uri: &str) -> Option<usize> {
let authority_start = uri.find("://")? + 3;
Some(
uri[authority_start..]
.find('/')
.map_or(uri.len(), |i| authority_start + i),
)
}
fn apply_uri(&self, path: &str, sep: char) -> Option<String> {
let src_path_start = Self::uri_path_start(&self.source_path).unwrap_or(0);
let inp_path_start = Self::uri_path_start(path).unwrap_or(0);
if !path[..inp_path_start].eq_ignore_ascii_case(&self.source_path[..src_path_start]) {
return None;
}
let src_path = &self.source_path[src_path_start..];
let inp_path = &path[inp_path_start..];
if !inp_path.starts_with(src_path) {
return None;
}
let remainder = &inp_path[src_path.len()..];
if !remainder.is_empty() && !remainder.starts_with('/') {
return None;
}
let child_parts: Vec<&str> = if remainder.is_empty() {
Vec::new()
} else {
remainder[1..].split('/').collect()
};
let mut result = self.destination_path.clone();
for part in &child_parts {
result.push(sep);
result.push_str(part);
}
if path.ends_with('/') && !result.ends_with(sep) {
result.push(sep);
}
Some(result)
}
fn apply_filesystem(&self, path: &str, case_insensitive: bool, sep: char) -> Option<String> {
let source_parts = split_path_parts(&self.source_path);
let path_parts = split_path_parts(path);
if path_parts.len() < source_parts.len() {
return None;
}
for (sp, pp) in source_parts.iter().zip(path_parts.iter()) {
let matches = if case_insensitive {
sp.eq_ignore_ascii_case(pp)
} else {
sp == pp
};
if !matches {
return None;
}
}
let remaining = &path_parts[source_parts.len()..];
let mut result = self.destination_path.clone();
for part in remaining {
result.push(sep);
result.push_str(part);
}
if has_trailing_slash(path, case_insensitive) && !result.ends_with(sep) {
result.push(sep);
}
Some(result)
}
}
fn split_path_parts(path: &str) -> Vec<&str> {
path.split(&['/', '\\'][..])
.filter(|s| !s.is_empty())
.collect()
}
fn has_trailing_slash(path: &str, windows: bool) -> bool {
if windows {
path.ends_with('\\') || path.ends_with('/')
} else {
path.ends_with('/')
}
}
#[must_use]
pub fn apply_rules(rules: &[PathMappingRule], path: &str) -> String {
apply_rules_with_format(rules, path, PathFormat::host())
}
#[must_use]
pub fn apply_rules_with_format(
rules: &[PathMappingRule],
path: &str,
output_format: PathFormat,
) -> String {
for rule in rules {
if let Some(mapped) = rule.apply_with_format(path, output_format) {
return mapped;
}
}
path.to_string()
}
pub fn is_uri(path: &str) -> bool {
crate::uri_path::is_uri(path)
}
#[cfg(test)]
mod tests {
use super::*;
const SPEC_JSON: &str = r#"{
"version": "pathmapping-1.0",
"path_mapping_rules": [
{
"source_path_format": "POSIX",
"source_path": "/home/user",
"destination_path": "/mnt/shared/user"
},
{
"source_path_format": "WINDOWS",
"source_path": "C:\\Users\\user",
"destination_path": "/mnt/shared/user"
}
]
}"#;
#[derive(Serialize, Deserialize)]
struct PathMappingFile {
version: String,
path_mapping_rules: Vec<PathMappingRule>,
}
#[test]
fn deserialize_spec_json() {
let file: PathMappingFile = serde_json::from_str(SPEC_JSON).unwrap();
assert_eq!(file.version, "pathmapping-1.0");
assert_eq!(file.path_mapping_rules.len(), 2);
assert_eq!(
file.path_mapping_rules[0].source_path_format,
PathFormat::Posix
);
assert_eq!(file.path_mapping_rules[0].source_path, "/home/user");
assert_eq!(
file.path_mapping_rules[0].destination_path,
"/mnt/shared/user"
);
assert_eq!(
file.path_mapping_rules[1].source_path_format,
PathFormat::Windows
);
assert_eq!(file.path_mapping_rules[1].source_path, "C:\\Users\\user");
assert_eq!(
file.path_mapping_rules[1].destination_path,
"/mnt/shared/user"
);
}
#[test]
fn serialize_roundtrip() {
let file: PathMappingFile = serde_json::from_str(SPEC_JSON).unwrap();
let json = serde_json::to_string(&file).unwrap();
let roundtrip: PathMappingFile = serde_json::from_str(&json).unwrap();
assert_eq!(
roundtrip.path_mapping_rules.len(),
file.path_mapping_rules.len()
);
for (a, b) in file
.path_mapping_rules
.iter()
.zip(roundtrip.path_mapping_rules.iter())
{
assert_eq!(a.source_path_format, b.source_path_format);
assert_eq!(a.source_path, b.source_path);
assert_eq!(a.destination_path, b.destination_path);
}
}
#[test]
fn serialize_posix_format() {
let rule = PathMappingRule {
source_path_format: PathFormat::Posix,
source_path: "/src".into(),
destination_path: "/dst".into(),
};
let json = serde_json::to_value(&rule).unwrap();
assert_eq!(json["source_path_format"], "POSIX");
}
#[test]
fn serialize_windows_format() {
let rule = PathMappingRule {
source_path_format: PathFormat::Windows,
source_path: "C:\\src".into(),
destination_path: "/dst".into(),
};
let json = serde_json::to_value(&rule).unwrap();
assert_eq!(json["source_path_format"], "WINDOWS");
}
#[test]
fn serialize_uri_format() {
let rule = PathMappingRule {
source_path_format: PathFormat::Uri,
source_path: "s3://bucket/assets".into(),
destination_path: "/mnt/assets".into(),
};
let json = serde_json::to_value(&rule).unwrap();
assert_eq!(json["source_path_format"], "URI");
}
#[test]
fn deserialize_uri_format() {
let json =
r#"{"source_path_format":"URI","source_path":"s3://bucket","destination_path":"/mnt"}"#;
let rule: PathMappingRule = serde_json::from_str(json).unwrap();
assert_eq!(rule.source_path_format, PathFormat::Uri);
}
#[test]
fn serialize_field_names_match_spec() {
let rule = PathMappingRule {
source_path_format: PathFormat::Posix,
source_path: "/a".into(),
destination_path: "/b".into(),
};
let json = serde_json::to_value(&rule).unwrap();
let obj = json.as_object().unwrap();
assert!(obj.contains_key("source_path_format"));
assert!(obj.contains_key("source_path"));
assert!(obj.contains_key("destination_path"));
assert_eq!(obj.len(), 3, "no extra fields");
}
}