use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Dialect {
#[default]
Posix,
Windows,
}
impl fmt::Display for Dialect {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Dialect::Posix => "posix",
Dialect::Windows => "windows",
})
}
}
pub const PROBE: &str = "echo %COMSPEC%";
pub fn read_probe(reply: &str) -> Dialect {
let reply = reply.trim().to_ascii_lowercase();
if reply.contains("cmd.exe") { Dialect::Windows } else { Dialect::Posix }
}
impl Dialect {
pub fn quote(&self, value: &str) -> String {
match self {
Dialect::Posix => format!("'{}'", value.replace('\'', "'\\''")),
Dialect::Windows => format!("\"{value}\""),
}
}
pub fn reject_unquotable(&self, value: &str) -> Result<(), String> {
match self {
Dialect::Posix => Ok(()),
Dialect::Windows if value.contains('"') => Err(format!("'{value}' contains a double quote, which cmd.exe cannot quote")),
Dialect::Windows if value.contains('%') => Err(format!("'{value}' contains a percent sign, which cmd.exe would expand as a variable")),
Dialect::Windows => Ok(()),
}
}
pub fn mkdir_p(&self, dir: &str) -> String {
let quoted = self.quote(dir);
match self {
Dialect::Posix => format!("mkdir -p {quoted}"),
Dialect::Windows => format!("if not exist {quoted} mkdir {quoted}"),
}
}
pub fn clear_dir(&self, dir: &str) -> String {
let quoted = self.quote(dir);
match self {
Dialect::Posix => format!("find {quoted} -mindepth 1 -maxdepth 1 -exec rm -rf {{}} +"),
Dialect::Windows => format!("del /f /q /s {quoted}\\* >nul 2>&1 & for /d %i in ({quoted}\\*) do @rd /s /q \"%i\" & exit /b 0"),
}
}
pub fn remove_file(&self, path: &str) -> String {
let quoted = self.quote(path);
match self {
Dialect::Posix => format!("rm -f {quoted}"),
Dialect::Windows => format!("del /f /q {quoted} >nul 2>&1 & exit /b 0"),
}
}
pub fn untar(&self, archive: &str, into: &str) -> String {
format!("tar xzf {} -C {}", self.quote(archive), self.quote(into))
}
pub fn tar_czf(&self, archive: &str, from_dir: &str) -> String {
format!("tar czf {} -C {} .", self.quote(archive), self.quote(from_dir))
}
pub fn list_dir(&self, dir: &str) -> String {
let quoted = self.quote(dir);
match self {
Dialect::Posix => format!("ls -1 {quoted} 2>/dev/null || true"),
Dialect::Windows => format!("dir /b {quoted} 2>nul & exit /b 0"),
}
}
pub fn file_exists(&self, path: &str) -> String {
let quoted = self.quote(path);
match self {
Dialect::Posix => format!("test -f {quoted}"),
Dialect::Windows => format!("if not exist {quoted} exit /b 1"),
}
}
pub fn and_then(&self, first: &str, second: &str) -> String {
format!("{first} && {second}")
}
pub fn run_in(&self, dir: &str, command: &str) -> String {
let quoted = self.quote(dir);
match self {
Dialect::Posix => format!("cd {quoted} && {command}"),
Dialect::Windows => format!("cd /d {quoted} && {command}"),
}
}
}
#[cfg(test)]
mod tests {
use super::{Dialect, PROBE, read_probe};
#[test]
fn a_command_runs_in_the_directory_it_belongs_to() {
assert_eq!(
Dialect::Posix.run_in("/srv/app", "docker compose up -d"),
"cd '/srv/app' && docker compose up -d"
);
let windows = Dialect::Windows.run_in("C:\\site", "docker compose up -d");
assert_eq!(windows, "cd /d \"C:\\site\" && docker compose up -d");
assert!(windows.contains("/d"), "without /d cmd.exe does not change drive: {windows}");
}
#[test]
fn a_directory_with_a_space_stays_one_argument() {
assert_eq!(Dialect::Posix.run_in("/srv/my app", "ls"), "cd '/srv/my app' && ls");
assert_eq!(Dialect::Windows.run_in("C:\\my site", "dir"), "cd /d \"C:\\my site\" && dir");
}
#[test]
fn the_probe_is_neutral() {
assert!(!PROBE.contains('\''), "single quotes are literal in cmd.exe: {PROBE}");
assert!(!PROBE.contains('>'), "redirection differs between the shells: {PROBE}");
assert!(!PROBE.contains("&&"), "keep the probe to a single command: {PROBE}");
}
#[test]
fn reads_the_replies_the_shells_actually_give() {
assert_eq!(read_probe("C:\\Windows\\system32\\cmd.exe"), Dialect::Windows);
assert_eq!(read_probe("%COMSPEC%"), Dialect::Posix);
}
#[test]
fn recognizes_cmd_whatever_the_case() {
assert_eq!(read_probe("C:\\WINDOWS\\SYSTEM32\\CMD.EXE"), Dialect::Windows);
}
#[test]
fn an_unrecognizable_reply_stays_posix() {
assert_eq!(read_probe(""), Dialect::Posix);
assert_eq!(read_probe("some login banner"), Dialect::Posix);
}
#[test]
fn quotes_per_dialect() {
assert_eq!(Dialect::Posix.quote("/var/www/my app"), "'/var/www/my app'");
assert_eq!(Dialect::Posix.quote("it's"), "'it'\\''s'");
assert_eq!(Dialect::Windows.quote("C:\\inetpub\\my site"), "\"C:\\inetpub\\my site\"");
}
#[test]
fn windows_refuses_what_it_cannot_quote() {
assert!(Dialect::Windows.reject_unquotable("C:\\ok\\path").is_ok());
assert!(Dialect::Windows.reject_unquotable("C:\\say \"hi\"").is_err());
assert!(Dialect::Windows.reject_unquotable("C:\\%TEMP%\\x").is_err());
assert!(Dialect::Posix.reject_unquotable("it's \"quoted\" 100%").is_ok());
}
#[test]
fn clearing_keeps_the_directory_itself() {
let posix = Dialect::Posix.clear_dir("/var/www/site");
assert!(posix.contains("-mindepth 1"), "{posix}");
assert!(!posix.contains("rm -rf '/var/www/site'"), "the directory itself must not be removed: {posix}");
let windows = Dialect::Windows.clear_dir("C:\\site");
assert!(windows.contains("\\*"), "only the contents are targeted: {windows}");
assert!(
!windows.contains("rd /s /q \"C:\\site\""),
"the directory itself must not be removed: {windows}"
);
}
#[test]
fn listing_and_clearing_tolerate_nothing_there() {
assert!(Dialect::Posix.list_dir("/backups").contains("|| true"));
assert!(Dialect::Windows.list_dir("C:\\backups").contains("exit /b 0"));
assert!(Dialect::Windows.clear_dir("C:\\site").contains("exit /b 0"));
assert!(Dialect::Windows.remove_file("C:\\x.tar.gz").contains("exit /b 0"));
}
#[test]
fn both_dialects_use_tar() {
assert!(Dialect::Posix.untar("/tmp/a.tar.gz", "/var/www").starts_with("tar xzf "));
assert!(Dialect::Windows.untar("C:\\a.tar.gz", "C:\\site").starts_with("tar xzf "));
assert_eq!(Dialect::Windows.untar("C:\\a.tar.gz", "C:\\site"), "tar xzf \"C:\\a.tar.gz\" -C \"C:\\site\"");
}
#[test]
fn paths_with_spaces_stay_one_argument() {
assert!(Dialect::Windows.mkdir_p("C:\\my site").contains("\"C:\\my site\""));
assert!(Dialect::Posix.mkdir_p("/var/my site").contains("'/var/my site'"));
assert!(Dialect::Windows.file_exists("C:\\my site\\a.txt").contains("\"C:\\my site\\a.txt\""));
}
#[test]
fn mkdir_tolerates_an_existing_directory() {
assert!(Dialect::Posix.mkdir_p("/var/www/site").starts_with("mkdir -p"));
assert!(Dialect::Windows.mkdir_p("C:\\site").starts_with("if not exist"));
}
}