//! What the file system of the working copy can represent (spec ยง7.7), //! and which paths of a state it cannot. //! //! Executable bits and symbolic links exist on Unix only; elsewhere a //! snapshot keeps the recorded executable bit and reads a symlink written //! out as a plain file back as the symlink. macOS and Windows file systems //! ignore case by default and Windows forbids some names, so paths that //! differ only in case or use such names are reported before they are //! written. use std::collections::BTreeMap; /// Capabilities of the working copy's file system. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Platform { pub exec_bits: bool, pub symlinks: bool, pub case_insensitive: bool, /// Windows' name rules: reserved device names, `<>:"\|?*`, trailing dots and spaces. pub windows_names: bool, } impl Platform { /// The platform oxy runs on, with its default file system. pub fn current() -> Platform { Platform { exec_bits: cfg!(unix), symlinks: cfg!(unix), case_insensitive: cfg!(any(target_os = "macos", target_os = "windows")), windows_names: cfg!(target_os = "windows"), } } pub fn windows() -> Platform { Platform { exec_bits: false, symlinks: false, case_insensitive: true, windows_names: true, } } pub fn macos() -> Platform { Platform { exec_bits: true, symlinks: true, case_insensitive: true, windows_names: false, } } /// Problems this platform would have with these paths. pub fn problems<'a>(&self, paths: impl IntoIterator) -> Vec { let paths: Vec<&str> = paths.into_iter().collect(); let mut out = Vec::new(); if self.case_insensitive { out.extend( case_collisions(&paths).into_iter().map(|(a, b)| { format!("{a} and {b} differ only in case; one will hide the other") }), ); } if self.windows_names { out.extend( paths .iter() .filter_map(|p| windows_name_problem(p).map(|why| format!("{p}: {why}"))), ); } out } } /// Pairs of paths equal but for case (including a directory's case). pub fn case_collisions(paths: &[&str]) -> Vec<(String, String)> { let mut seen: BTreeMap = BTreeMap::new(); let mut out = Vec::new(); // Every prefix counts: `Docs/a` and `docs/b` put two directories in one place. let mut prefixes: Vec<&str> = Vec::new(); for p in paths { let mut end = 0; for part in p.split('/') { end += part.len(); prefixes.push(&p[..end]); end += 1; } } prefixes.sort(); prefixes.dedup(); for p in prefixes { let key = p.to_lowercase(); match seen.get(&key) { Some(first) if *first != p => out.push((first.to_string(), p.to_string())), Some(_) => {} None => { seen.insert(key, p); } } } out } /// Why Windows would refuse a path, if it would. pub fn windows_name_problem(path: &str) -> Option<&'static str> { const RESERVED: &[&str] = &[ "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", ]; for part in path.split('/') { if part .chars() .any(|c| matches!(c, '<' | '>' | ':' | '"' | '\\' | '|' | '?' | '*') || (c as u32) < 32) { return Some("contains a character Windows does not allow in names"); } if part.ends_with('.') || part.ends_with(' ') { return Some("a name ending in a dot or space cannot be created on Windows"); } let stem = part.split('.').next().unwrap_or("").to_ascii_uppercase(); if RESERVED.contains(&stem.as_str()) { return Some("a reserved device name on Windows"); } } None } #[cfg(test)] mod tests { use super::*; #[test] fn collisions_and_names() { assert_eq!( case_collisions(&["README.md", "readme.md", "src/a.rs"]), vec![("README.md".into(), "readme.md".into())] ); assert_eq!( case_collisions(&["Docs/a", "docs/b"]), vec![("Docs".into(), "docs".into())] ); assert!(case_collisions(&["a/b", "a/c", "b"]).is_empty()); assert!(windows_name_problem("src/con.rs").is_some()); assert!(windows_name_problem("notes/a:b.txt").is_some()); assert!(windows_name_problem("trailing./x").is_some()); assert!(windows_name_problem("src/console.rs").is_none()); let w = Platform::windows(); assert_eq!(w.problems(["A.txt", "a.txt", "aux.c"]).len(), 2); assert!(Platform { exec_bits: true, symlinks: true, case_insensitive: false, windows_names: false } .problems(["A", "a"]) .is_empty()); } }