Files
rojo/src/syncback/file_names.rs

172 lines
5.8 KiB
Rust

//! Contains logic for generating new file names for Instances based on their
//! middleware.
use std::borrow::Cow;
use anyhow::Context;
use rbx_dom_weak::Instance;
use crate::{snapshot::InstanceWithMeta, snapshot_middleware::Middleware};
pub fn name_for_inst<'a>(
middleware: Middleware,
new_inst: &'a Instance,
old_inst: Option<InstanceWithMeta<'a>>,
) -> anyhow::Result<Cow<'a, str>> {
if let Some(old_inst) = old_inst {
if let Some(source) = old_inst.metadata().relevant_paths.first() {
source
.file_name()
.and_then(|s| s.to_str())
.map(Cow::Borrowed)
.context("sources on the file system should be valid unicode and not be stubs")
} else {
// This is technically not /always/ true, but we want to avoid
// running syncback on anything that has no instigating source
// anyway.
anyhow::bail!(
"members of 'old' trees should have an instigating source. Somehow, {} did not.",
old_inst.name(),
);
}
} else {
Ok(match middleware {
Middleware::Dir
| Middleware::CsvDir
| Middleware::ServerScriptDir
| Middleware::ClientScriptDir
| Middleware::ModuleScriptDir => {
if validate_file_name(&new_inst.name).is_err() {
Cow::Owned(slugify_name(&new_inst.name))
} else {
Cow::Borrowed(&new_inst.name)
}
}
_ => {
let extension = extension_for_middleware(middleware);
let slugified;
let final_name = if validate_file_name(&new_inst.name).is_err() {
slugified = slugify_name(&new_inst.name);
&slugified
} else {
&new_inst.name
};
Cow::Owned(format!("{final_name}.{extension}"))
}
})
}
}
/// Returns the extension a provided piece of middleware is supposed to use.
pub fn extension_for_middleware(middleware: Middleware) -> &'static str {
match middleware {
Middleware::Csv => "csv",
Middleware::JsonModel => "model.json",
Middleware::Json => "json",
Middleware::ServerScript => "server.luau",
Middleware::ClientScript => "client.luau",
Middleware::ModuleScript => "luau",
Middleware::PluginScript => "plugin.luau",
Middleware::Project => "project.json",
Middleware::Rbxm => "rbxm",
Middleware::Rbxmx => "rbxmx",
Middleware::Toml => "toml",
Middleware::Text => "txt",
Middleware::Yaml => "yml",
Middleware::LegacyServerScript
| Middleware::LegacyClientScript
| Middleware::RunContextServerScript
| Middleware::RunContextClientScript => {
todo!("syncback does not work on the middleware {middleware:?} yet")
}
// These are manually specified and not `_` to guard against future
// middleware additions missing this function.
Middleware::Ignore => unimplemented!("syncback does not work on Ignore middleware"),
Middleware::Dir
| Middleware::CsvDir
| Middleware::ServerScriptDir
| Middleware::ClientScriptDir
| Middleware::ModuleScriptDir => {
unimplemented!("directory middleware requires special treatment")
}
}
}
/// A list of file names that are not valid on Windows.
const INVALID_WINDOWS_NAMES: [&str; 22] = [
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
/// A list of all characters that are outright forbidden to be included
/// in a file's name.
const FORBIDDEN_CHARS: [char; 9] = ['<', '>', ':', '"', '/', '|', '?', '*', '\\'];
/// Slugifies a name by replacing forbidden characters with underscores
/// and ensuring the result is a valid file name
pub fn slugify_name(name: &str) -> String {
let mut result = String::with_capacity(name.len());
for ch in name.chars() {
if FORBIDDEN_CHARS.contains(&ch) {
result.push('_');
} else {
result.push(ch);
}
}
// Handle Windows reserved names by appending an underscore
let result_lower = result.to_lowercase();
for forbidden in INVALID_WINDOWS_NAMES {
if result_lower == forbidden.to_lowercase() {
result.push('_');
break;
}
}
while result.ends_with(' ') || result.ends_with('.') {
result.pop();
}
if result.is_empty() || result.chars().all(|c| c == '_') {
result = "instance".to_string();
}
result
}
/// Validates a provided file name to ensure it's allowed on the file system. An
/// error is returned if the name isn't allowed, indicating why.
/// This takes into account rules for Windows, MacOS, and Linux.
///
/// In practice however, these broadly overlap so the only unexpected behavior
/// is Windows, where there are 22 reserved names.
pub fn validate_file_name<S: AsRef<str>>(name: S) -> anyhow::Result<()> {
let str = name.as_ref();
if str.ends_with(' ') {
anyhow::bail!("file names cannot end with a space")
}
if str.ends_with('.') {
anyhow::bail!("file names cannot end with '.'")
}
for char in str.chars() {
if FORBIDDEN_CHARS.contains(&char) {
anyhow::bail!("file names cannot contain <, >, :, \", /, |, ?, *, or \\")
} else if char.is_control() {
anyhow::bail!("file names cannot contain control characters")
}
}
for forbidden in INVALID_WINDOWS_NAMES {
if str == forbidden {
anyhow::bail!("files cannot be named {str}")
}
}
Ok(())
}