OXYGENHUB

Coverage / crates/oxy-core/src/portable.rs 97.2%

104 of 107 lines run, 20 of 37 functions · run #257 on main · run missed

1
//! What the file system of the working copy can represent (spec §7.7),
2
//! and which paths of a state it cannot.
3
//!
4
//! Executable bits and symbolic links exist on Unix only; elsewhere a
5
//! snapshot keeps the recorded executable bit and reads a symlink written
6
//! out as a plain file back as the symlink. macOS and Windows file systems
7
//! ignore case by default and Windows forbids some names, so paths that
8
//! differ only in case or use such names are reported before they are
9
//! written.
10
11
use std::collections::BTreeMap;
12
13
/// Capabilities of the working copy's file system.
14
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15
pub struct Platform {
16
    pub exec_bits: bool,
17
    pub symlinks: bool,
18
    pub case_insensitive: bool,
19
    /// Windows' name rules: reserved device names, `<>:"\|?*`, trailing dots and spaces.
20
    pub windows_names: bool,
21
}
22
23
impl Platform {
24
    /// The platform oxy runs on, with its default file system.
252348×
    pub fn current() -> Platform {
262348×
        Platform {
272348×
            exec_bits: cfg!(unix),
282348×
            symlinks: cfg!(unix),
292348×
            case_insensitive: cfg!(any(target_os = "macos", target_os = "windows")),
302348×
            windows_names: cfg!(target_os = "windows"),
312348×
        }
322348×
    }
33
347×
    pub fn windows() -> Platform {
357×
        Platform {
367×
            exec_bits: false,
377×
            symlinks: false,
387×
            case_insensitive: true,
397×
            windows_names: true,
407×
        }
417×
    }
42
431×
    pub fn macos() -> Platform {
441×
        Platform {
451×
            exec_bits: true,
461×
            symlinks: true,
471×
            case_insensitive: true,
481×
            windows_names: false,
491×
        }
501×
    }
51
52
    /// Problems this platform would have with these paths.
53468×
    pub fn problems<'a>(&self, paths: impl IntoIterator<Item = &'a str>) -> Vec<String> {
54468×
        let paths: Vec<&str> = paths.into_iter().collect();
55468×
        let mut out = Vec::new();
56468×
        if self.case_insensitive {
577×
            out.extend(
587×
                case_collisions(&paths).into_iter().map(|(a, b)| {
593×
                    format!("{a} and {b} differ only in case; one will hide the other")
603×
                }),
61
            );
62461×
        }
63468×
        if self.windows_names {
646×
            out.extend(
656×
                paths
666×
                    .iter()
677×
                    .filter_map(|p| windows_name_problem(p).map(|why| format!("{p}: {why}"))),
68
            );
69462×
        }
70468×
        out
71468×
    }
72
}
73
74
/// Pairs of paths equal but for case (including a directory's case).
7510×
pub fn case_collisions(paths: &[&str]) -> Vec<(String, String)> {
7610×
    let mut seen: BTreeMap<String, &str> = BTreeMap::new();
7710×
    let mut out = Vec::new();
78
    // Every prefix counts: `Docs/a` and `docs/b` put two directories in one place.
7910×
    let mut prefixes: Vec<&str> = Vec::new();
8019×
    for p in paths {
8119×
        let mut end = 0;
8226×
        for part in p.split('/') {
8326×
            end += part.len();
8426×
            prefixes.push(&p[..end]);
8526×
            end += 1;
8626×
        }
87
    }
8810×
    prefixes.sort();
8910×
    prefixes.dedup();
9023×
    for p in prefixes {
9123×
        let key = p.to_lowercase();
9223×
        match seen.get(&key) {
935×
            Some(first) if *first != p => out.push((first.to_string(), p.to_string())),
940×
            Some(_) => {}
9518×
            None => {
9618×
                seen.insert(key, p);
9718×
            }
98
        }
99
    }
10010×
    out
10110×
}
102
103
/// Why Windows would refuse a path, if it would.
10411×
pub fn windows_name_problem(path: &str) -> Option<&'static str> {
105
    const RESERVED: &[&str] = &[
106
        "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
107
        "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
108
    ];
10915×
    for part in path.split('/') {
11015×
        if part
11115×
            .chars()
11282×
            .any(|c| matches!(c, '<' | '>' | ':' | '"' | '\\' | '|' | '?' | '*') || (c as u32) < 32)
113
        {
1141×
            return Some("contains a character Windows does not allow in names");
11514×
        }
11614×
        if part.ends_with('.') || part.ends_with(' ') {
1171×
            return Some("a name ending in a dot or space cannot be created on Windows");
11813×
        }
11913×
        let stem = part.split('.').next().unwrap_or("").to_ascii_uppercase();
12013×
        if RESERVED.contains(&stem.as_str()) {
1213×
            return Some("a reserved device name on Windows");
12210×
        }
123
    }
1246×
    None
12511×
}
126
127
#[cfg(test)]
128
mod tests {
129
    use super::*;
130
131
    #[test]
1321×
    fn collisions_and_names() {
1331×
        assert_eq!(
1341×
            case_collisions(&["README.md", "readme.md", "src/a.rs"]),
1351×
            vec![("README.md".into(), "readme.md".into())]
136
        );
1371×
        assert_eq!(
1381×
            case_collisions(&["Docs/a", "docs/b"]),
1391×
            vec![("Docs".into(), "docs".into())]
140
        );
1411×
        assert!(case_collisions(&["a/b", "a/c", "b"]).is_empty());
1421×
        assert!(windows_name_problem("src/con.rs").is_some());
1431×
        assert!(windows_name_problem("notes/a:b.txt").is_some());
1441×
        assert!(windows_name_problem("trailing./x").is_some());
1451×
        assert!(windows_name_problem("src/console.rs").is_none());
1461×
        let w = Platform::windows();
1471×
        assert_eq!(w.problems(["A.txt", "a.txt", "aux.c"]).len(), 2);
1481×
        assert!(Platform {
1491×
            exec_bits: true,
1501×
            symlinks: true,
1511×
            case_insensitive: false,
1521×
            windows_names: false
1531×
        }
1541×
        .problems(["A", "a"])
1551×
        .is_empty());
1561×
    }
157
}