diff --git a/src/cli.rs b/src/cli.rs index 26a34ea..40f382f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -287,7 +287,7 @@ pub fn run(sub: Subcommand, no_manifest_update: bool, try_manifest_update: bool) game, name, &roots, - &StrictPath::from_std_path_buf(&app_dir()), + &StrictPath::from(app_dir()), &launchers, &filter, &wine_prefix, diff --git a/src/cli/parse.rs b/src/cli/parse.rs index ef9a45f..8345553 100644 --- a/src/cli/parse.rs +++ b/src/cli/parse.rs @@ -20,8 +20,9 @@ fn parse_strict_path(path: &str) -> Result { } fn parse_existing_strict_path(path: &str) -> Result { - let sp = StrictPath::new(path.to_owned()); - std::fs::canonicalize(sp.interpret())?; + let cwd = StrictPath::cwd(); + let sp = StrictPath::relative(path.to_owned(), Some(cwd.raw())); + sp.metadata()?; Ok(sp) } @@ -912,7 +913,10 @@ mod tests { try_manifest_update: false, sub: Some(Subcommand::Restore { preview: true, - path: Some(StrictPath::new(s("tests/backup"))), + path: Some(StrictPath::relative( + s("tests/backup"), + Some(StrictPath::cwd().interpret()), + )), force: true, api: true, sort: Some(CliSort::Name), diff --git a/src/cli/report.rs b/src/cli/report.rs index 7065eb9..964de9d 100644 --- a/src/cli/report.rs +++ b/src/cli/report.rs @@ -547,14 +547,6 @@ mod tests { testing::s, }; - fn drive() -> String { - if cfg!(target_os = "windows") { - StrictPath::new(s("foo")).render()[..2].to_string() - } else { - s("") - } - } - #[test] fn can_render_in_standard_mode_with_minimal_input() { let mut reporter = Reporter::standard(); @@ -566,15 +558,12 @@ mod tests { &DuplicateDetector::default(), ); assert_eq!( - format!( - r#" + r#" Overall: Games: 0 Size: 0 B - Location: {}/dev/null - "#, - &drive() - ) + Location: /dev/null + "# .trim_end(), reporter.render(&StrictPath::new(s("/dev/null"))) ) @@ -631,8 +620,8 @@ Overall: assert_eq!( r#" foo [100.00 KiB]: - - /file1 - - [FAILED] /file2 + - /file1 + - [FAILED] /file2 - [FAILED] HKEY_CURRENT_USER/Key1 - HKEY_CURRENT_USER/Key2 - HKEY_CURRENT_USER/Key3 @@ -641,10 +630,9 @@ foo [100.00 KiB]: Overall: Games: 1 Size: 100.00 KiB / 150.00 KiB - Location: /dev/null + Location: /dev/null "# - .trim() - .replace("", &drive()), + .trim(), reporter.render(&StrictPath::new(s("/dev/null"))) ); } @@ -708,18 +696,17 @@ Overall: assert_eq!( r#" foo [1 B]: - - /file1 + - /file1 bar [3 B]: - - /file2 + - /file2 Overall: Games: 2 Size: 4 B - Location: /dev/null + Location: /dev/null "# - .trim() - .replace("", &drive()), + .trim(), reporter.render(&StrictPath::new(s("/dev/null"))) ); } @@ -734,20 +721,20 @@ Overall: game_name: s("foo"), found_files: hash_set! { ScannedFile { - path: StrictPath::new(format!("{}/backup/file1", drive())), + path: StrictPath::new(s("/backup/file1")), size: 102_400, hash: "1".to_string(), - original_path: Some(StrictPath::new(format!("{}/original/file1", drive()))), + original_path: Some(StrictPath::new(s("/original/file1"))), ignored: false, change: Default::default(), container: None, redirected: None, }, ScannedFile { - path: StrictPath::new(format!("{}/backup/file2", drive())), + path: StrictPath::new(s("/backup/file2")), size: 51_200, hash: "2".to_string(), - original_path: Some(StrictPath::new(format!("{}/original/file2", drive()))), + original_path: Some(StrictPath::new(s("/original/file2"))), ignored: false, change: Default::default(), container: None, @@ -764,16 +751,15 @@ Overall: assert_eq!( r#" foo [150.00 KiB]: - - /original/file1 - - /original/file2 + - /original/file1 + - /original/file2 Overall: Games: 1 Size: 150.00 KiB - Location: /dev/null + Location: /dev/null "# - .trim() - .replace("", &drive()), + .trim(), reporter.render(&StrictPath::new(s("/dev/null"))) ); } @@ -818,16 +804,15 @@ Overall: assert_eq!( r#" foo [100.00 KiB] [DUPLICATES]: - - [DUPLICATED] /file1 + - [DUPLICATED] /file1 - [DUPLICATED] HKEY_CURRENT_USER/Key1 Overall: Games: 1 Size: 100.00 KiB - Location: /dev/null + Location: /dev/null "# - .trim() - .replace("", &drive()), + .trim(), reporter.render(&StrictPath::new(s("/dev/null"))) ); } @@ -876,21 +861,20 @@ Overall: assert_eq!( r#" foo [4 B] [Δ]: - - [Δ] /different - - [+] /new - - /same - - /unknown + - [Δ] /different + - [+] /new + - /same + - /unknown bar [1 B] [+]: - - [+] /brand-new + - [+] /brand-new Overall: Games: 2 [+1] [Δ1] Size: 5 B - Location: /dev/null + Location: /dev/null "# - .trim() - .replace("", &drive()), + .trim(), reporter.render(&StrictPath::new(s("/dev/null"))) ); } @@ -980,11 +964,11 @@ Overall: "decision": "Processed", "change": "Same", "files": { - "/file1": { + "/file1": { "change": "Unknown", "bytes": 100 }, - "/file2": { + "/file2": { "failed": true, "change": "Unknown", "bytes": 50 @@ -1011,8 +995,7 @@ Overall: } } "# - .trim() - .replace("", &drive()), + .trim(), reporter.render(&StrictPath::new(s("/dev/null"))) ); } @@ -1027,20 +1010,20 @@ Overall: game_name: s("foo"), found_files: hash_set! { ScannedFile { - path: StrictPath::new(format!("{}/backup/file1", drive())), + path: StrictPath::new(s("/backup/file1")), size: 100, hash: "1".to_string(), - original_path: Some(StrictPath::new(format!("{}/original/file1", drive()))), + original_path: Some(StrictPath::new(s("/original/file1"))), ignored: false, change: Default::default(), container: None, redirected: None, }, ScannedFile { - path: StrictPath::new(format!("{}/backup/file2", drive())), + path: StrictPath::new(s("/backup/file2")), size: 50, hash: "2".to_string(), - original_path: Some(StrictPath::new(format!("{}/original/file2", drive()))), + original_path: Some(StrictPath::new(s("/original/file2"))), ignored: false, change: Default::default(), container: None, @@ -1073,11 +1056,11 @@ Overall: "decision": "Processed", "change": "Same", "files": { - "/original/file1": { + "/original/file1": { "change": "Unknown", "bytes": 100 }, - "/original/file2": { + "/original/file2": { "change": "Unknown", "bytes": 50 } @@ -1087,8 +1070,7 @@ Overall: } } "# - .trim() - .replace("", &drive()), + .trim(), reporter.render(&StrictPath::new(s("/dev/null"))) ); } @@ -1149,7 +1131,7 @@ Overall: "decision": "Processed", "change": "Same", "files": { - "/file1": { + "/file1": { "change": "Unknown", "bytes": 100, "duplicatedBy": [ @@ -1169,8 +1151,7 @@ Overall: } } "# - .trim() - .replace("", &drive()), + .trim(), reporter.render(&StrictPath::new(s("/dev/null"))) ); } @@ -1218,19 +1199,19 @@ Overall: "decision": "Processed", "change": "Different", "files": { - "/different": { + "/different": { "change": "Different", "bytes": 1 }, - "/new": { + "/new": { "change": "New", "bytes": 1 }, - "/same": { + "/same": { "change": "Same", "bytes": 1 }, - "/unknown": { + "/unknown": { "change": "Unknown", "bytes": 1 } @@ -1240,8 +1221,7 @@ Overall: } } "# - .trim() - .replace("", &drive()), + .trim(), reporter.render(&StrictPath::new(s("/dev/null"))) ); } diff --git a/src/gui/app.rs b/src/gui/app.rs index b083195..8ef21f8 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -402,7 +402,7 @@ impl App { &game, &key, &roots, - &StrictPath::from_std_path_buf(&app_dir()), + &StrictPath::from(app_dir()), &launchers, &filter, &None, diff --git a/src/path.rs b/src/path.rs index 7d2f999..7d6b2ff 100644 --- a/src/path.rs +++ b/src/path.rs @@ -5,23 +5,21 @@ use once_cell::sync::Lazy; use crate::{ prelude::{AnyError, SKIP}, - resource::manifest::Os, + resource::manifest::{placeholder, Os}, }; -#[cfg(target_os = "windows")] -const TYPICAL_SEPARATOR: &str = "\\"; -#[cfg(target_os = "windows")] -const ATYPICAL_SEPARATOR: &str = "/"; - -#[cfg(not(target_os = "windows"))] -const TYPICAL_SEPARATOR: &str = "/"; -#[cfg(not(target_os = "windows"))] -const ATYPICAL_SEPARATOR: &str = "\\"; +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Drive { + Root, + Windows(String), +} -#[allow(dead_code)] -const UNC_PREFIX: &str = "\\\\"; -#[allow(dead_code)] -const UNC_LOCAL_PREFIX: &str = "\\\\?\\"; +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Canonical { + Valid(String), + Unsupported, + Inaccessible, +} pub enum CommonPath { Config, @@ -59,13 +57,6 @@ impl CommonPath { pub fn get_or_skip(&self) -> &str { self.get().unwrap_or(SKIP) } - - pub fn try_get(&self) -> Result<&str, StrictPathError> { - match self.get() { - Some(path) => Ok(path), - None => Err(StrictPathError::Unmappable), - } - } } #[derive(Debug)] @@ -74,207 +65,18 @@ pub enum SetFileTimeError { InvalidTimestamp, } -pub fn parse_home(path: &str) -> String { - if path == "~" || path.starts_with("~/") || path.starts_with("~\\") { - if let Some(home) = CommonPath::Home.get() { - return path.replacen('~', home, 1); - } - } - - path.to_owned() -} - -fn normalize(path: &str) -> String { - let mut path = path.trim().to_string(); - - #[cfg(target_os = "windows")] - if path.starts_with('/') { - let drive = &render_pathbuf(&std::env::current_dir().unwrap())[..2]; - path = format!("{}{}", drive, path) - } - - path = parse_home(&path).replace(ATYPICAL_SEPARATOR, TYPICAL_SEPARATOR); - - // On Windows, canonicalizing "C:" or "C:/" yields the current directory, - // but "C:\" works. - #[cfg(target_os = "windows")] - if path.ends_with(':') { - path += TYPICAL_SEPARATOR; - } - - path -} - -// Based on: -// https://github.com/rust-lang/cargo/blob/f84f3f8c630c75a1ec01b818ff469d3496228c6b/src/cargo/util/paths.rs#L61-L86 -fn parse_dots(path: &str, basis: &str) -> String { - let mut components = std::path::Path::new(&path).components().peekable(); - let mut ret = if let Some(c @ std::path::Component::Prefix(..)) = components.peek().cloned() { - components.next(); - std::path::PathBuf::from(c.as_os_str()) - } else { - std::path::PathBuf::from(basis) - }; - - for component in components { - match component { - std::path::Component::Prefix(..) => unreachable!(), - std::path::Component::RootDir => { - ret.push(component.as_os_str()); - } - std::path::Component::CurDir => {} - std::path::Component::ParentDir => { - ret.pop(); - } - std::path::Component::Normal(c) => { - let lossy = c.to_string_lossy(); - if lossy.contains(':') { - // This can happen if the manifest contains invalid paths, - // such as `/`. In this example, `` - // means we could try to push `C:` in the middle of the path, - // which would truncate the rest of the path up to that point, - // causing us to check the entire home folder. - // We escape it so that it (likely) just won't be found, - // rather than finding something irrelevant. - ret.push(lossy.replace(':', "_")); - } else { - ret.push(c); - } - } - } - } - - render_pathbuf(&ret) -} - -/// Convert a raw, possibly user-provided path into a suitable form for internal use. -/// On Windows, this produces UNC paths. -fn interpret>(path: P, basis: &Option) -> String { - let normalized = normalize(&path.into()); - if normalized.is_empty() { - return normalized; - } - - let absolutized = if std::path::Path::new(&normalized).is_absolute() { - normalized - } else { - render_pathbuf( - &match basis { - None => std::env::current_dir().unwrap(), - Some(b) => std::path::Path::new(&normalize(b)).to_path_buf(), - } - .join(normalized), - ) - }; - - match std::fs::canonicalize(&absolutized) { - Ok(x) => render_pathbuf(&x), - Err(_) => { - let dedotted = parse_dots( - &absolutized, - &render_pathbuf(&match basis { - None => std::env::current_dir().unwrap(), - Some(b) => std::path::Path::new(&normalize(b)).to_path_buf(), - }), - ); - format!( - "{}{}", - if cfg!(target_os = "windows") && !dedotted.starts_with(UNC_LOCAL_PREFIX) { - UNC_LOCAL_PREFIX - } else { - "" - }, - dedotted.replace(ATYPICAL_SEPARATOR, TYPICAL_SEPARATOR) - ) - } - } -} - -/// Convert a path into a nice form for display and storage. -/// On Windows, this produces non-UNC paths. -fn render>(path: P) -> String { - path.into().replace(UNC_LOCAL_PREFIX, "").replace('\\', "/") -} - pub fn render_pathbuf(value: &std::path::Path) -> String { value.display().to_string() } -/// Convert a path into a format that is amenable to zipped comparison when splitting on `/`. -/// The resulting path should not be used for actual file lookup. -/// This relies on `render()` removing UNC prefixes when possible, so that -/// `C:` and `\\?\C:` will end up normalizing to `C:`. -/// For Linux-style paths, `C:` is inserted before path-initial `/` to avoid the split vec -/// starting with `""`. -fn splittable(path: &StrictPath) -> String { - let rendered = path.render(); - let prefixed = if rendered.starts_with('/') { - format!("C:{}", rendered) - } else { - rendered - }; - match prefixed.strip_suffix('/') { - Some(x) => x.to_string(), - _ => prefixed, - } -} - +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum StrictPathError { + Empty, Relative, Unmappable, Unsupported, } -/// This is a newer alternative to `interpret`. -/// It avoids canonicalization, because we may want to represent invalid or non-native paths, -/// and we also want to flag relative paths as invalid rather than assume the current working directory. -/// It also handles `~` and some manifest placeholders. -/// For now, you should still use `interpret` in most cases, though. -pub fn resolve(raw: impl Into) -> Result { - use crate::resource::manifest::placeholder; - - let mut path = parse_home(&raw.into()) - .replace(placeholder::HOME, CommonPath::Home.try_get()?) - .replace(placeholder::OS_USER_NAME, &crate::prelude::OS_USERNAME); - - if Os::HOST == Os::Windows { - path = path - .trim_end_matches(['/', '\\']) - .replace(placeholder::WIN_APP_DATA, CommonPath::Data.try_get()?) - .replace(placeholder::WIN_LOCAL_APP_DATA, CommonPath::DataLocal.try_get()?) - .replace(placeholder::WIN_DOCUMENTS, CommonPath::Document.try_get()?) - .replace(placeholder::WIN_PUBLIC, CommonPath::Public.try_get()?) - .replace(placeholder::WIN_PROGRAM_DATA, "C:/ProgramData") - .replace(placeholder::WIN_DIR, "C:/Windows"); - - // On Windows, canonicalizing "C:" or "C:/" yields the current directory, - // but "C:\" works. - #[cfg(target_os = "windows")] - if path.ends_with(':') { - path += "\\"; - } - - if !path.contains(':') && !path.starts_with("\\\\") { - return Err(StrictPathError::Unsupported); - } - } else { - path = path - .trim_end_matches('/') - .replace(placeholder::XDG_DATA, CommonPath::Data.try_get()?) - .replace(placeholder::XDG_CONFIG, CommonPath::Config.try_get()?); - - if path.contains(':') || path.starts_with("//") || path.starts_with('\\') { - return Err(StrictPathError::Unsupported); - } - } - - if std::path::PathBuf::from(&path).is_relative() { - return Err(StrictPathError::Relative); - } - - Ok(path) -} - /// This is a wrapper around paths to make it more obvious when we're /// converting between different representations. This also handles /// things like `~`. @@ -282,7 +84,7 @@ pub fn resolve(raw: impl Into) -> Result { pub struct StrictPath { raw: String, basis: Option, - interpreted: Arc>>, + canonical: Arc>>, } impl Eq for StrictPath {} @@ -328,7 +130,7 @@ impl StrictPath { Self { raw, basis: None, - interpreted: Arc::new(Mutex::new(None)), + canonical: Arc::new(Mutex::new(None)), } } @@ -336,17 +138,20 @@ impl StrictPath { Self { raw, basis, - interpreted: Arc::new(Mutex::new(None)), + canonical: Arc::new(Mutex::new(None)), } } + pub fn cwd() -> Self { + Self::from(std::env::current_dir().unwrap()) + } + pub fn reset(&mut self, raw: String) { self.raw = raw; - let mut interpreted = self.interpreted.lock().unwrap(); - *interpreted = None; + self.invalidate_cache(); } - pub fn from_std_path_buf(path_buf: &std::path::Path) -> Self { + fn from_std_path_buf(path_buf: &std::path::Path) -> Self { Self::new(render_pathbuf(path_buf)) } @@ -354,10 +159,6 @@ impl StrictPath { std::path::PathBuf::from(&self.interpret()) } - pub fn as_std_path_buf_raw(&self) -> std::path::PathBuf { - std::path::PathBuf::from(&self.raw()) - } - pub fn raw(&self) -> String { self.raw.to_string() } @@ -365,19 +166,191 @@ impl StrictPath { /// For any paths that we store the entire time the GUI is running, like in the config, /// we sometimes want to refresh in case we have stale data. pub fn invalidate_cache(&self) { - let mut cached = self.interpreted.lock().unwrap(); + let mut cached = self.canonical.lock().unwrap(); *cached = None; } - pub fn interpret(&self) -> String { - let mut cached = self.interpreted.lock().unwrap(); - match &*cached { - None => { - let computed = interpret(&self.raw, &self.basis); - *cached = Some(computed.clone()); - computed + fn analyze(&self) -> (Option, Vec) { + use std::path::{Component, PathBuf, Prefix}; + + let mut drive = None; + let mut parts = vec![]; + + for (i, component) in PathBuf::from(self.raw.trim()).components().enumerate() { + match component { + Component::Prefix(prefix) => { + let mapped = match prefix.kind() { + Prefix::Verbatim(id) => format!(r"\\?\{}", id.to_string_lossy()), + Prefix::VerbatimUNC(server, share) => { + format!(r"\\?\UNC\{}\{}", server.to_string_lossy(), share.to_string_lossy()) + } + Prefix::VerbatimDisk(id) => format!("{}:", id.to_ascii_uppercase() as char), + Prefix::DeviceNS(id) => format!(r"\\.\{}", id.to_string_lossy()), + Prefix::UNC(server, share) => { + let server = server.to_string_lossy(); + let share = share.to_string_lossy(); + + if server == "?" && share.len() == 2 && share.ends_with(':') { + // This happens with forward slashes: `//?/C:` -> `C:` + share.to_uppercase() + } else { + format!(r"\\{}\{}", server, share) + } + } + Prefix::Disk(id) => format!("{}:", id.to_ascii_uppercase() as char), + }; + drive = Some(Drive::Windows(mapped)); + } + Component::RootDir => { + if i == 0 { + drive = Some(Drive::Root); + } + } + Component::CurDir => { + if i == 0 { + if let Some(basis) = &self.basis { + (drive, parts) = Self::new(basis.clone()).analyze(); + } + } + } + Component::ParentDir => { + if i == 0 { + if let Some(basis) = &self.basis { + (drive, parts) = Self::new(basis.clone()).analyze(); + } + } + parts.pop(); + } + Component::Normal(part) => { + let part = part.to_string_lossy().to_string(); + + if i == 0 { + let mapped = match part.as_str() { + "~" | placeholder::HOME => CommonPath::Home.get(), + placeholder::XDG_CONFIG => CommonPath::Config.get(), + placeholder::XDG_DATA | placeholder::WIN_APP_DATA => CommonPath::Data.get(), + placeholder::WIN_LOCAL_APP_DATA => CommonPath::DataLocal.get(), + placeholder::WIN_DOCUMENTS => CommonPath::Document.get(), + placeholder::WIN_PUBLIC => CommonPath::Public.get(), + placeholder::WIN_PROGRAM_DATA => Some("C:/ProgramData"), + placeholder::WIN_DIR => Some("C:/Windows"), + _ => None, + }; + + if let Some(mapped) = mapped { + (drive, parts) = Self::new(mapped.to_string()).analyze(); + continue; + } else if let Some(basis) = &self.basis { + (drive, parts) = Self::new(basis.clone()).analyze(); + } + } + + if part == placeholder::OS_USER_NAME { + parts.push(crate::prelude::OS_USERNAME.to_string()); + continue; + } + + // TODO: Do we still need this? + if part.contains(':') { + // This could happen if the user entered an invalid path like `C:\foo/C:\bar`. + // It used to be possible if the manifest contained something like `/`, + // but we only expand those placeholders in the initial position now. + // We escape it so that it (likely) just won't be found, rather than finding something irrelevant. + parts.push(part.replace(':', "_")); + continue; + } + + parts.push(part); + } + } + } + + (drive, parts) + } + + fn display(&self) -> String { + if self.raw.is_empty() { + return "".to_string(); + } + + match self.analyze() { + (Some(Drive::Root), parts) => format!("/{}", parts.join("/")), + (Some(Drive::Windows(id)), parts) => { + format!("{}/{}", id, parts.join("/")) } - Some(cached) => cached.clone(), + (None, parts) => parts.join("/"), + } + } + + fn access(&self) -> Result { + if cfg!(target_os = "windows") { + self.access_windows() + } else { + self.access_nonwindows() + } + } + + fn access_windows(&self) -> Result { + if self.raw.is_empty() { + return Err(StrictPathError::Empty); + } + + match self.analyze() { + (Some(Drive::Root), parts) => Ok(format!("C:\\{}", parts.join("\\"))), + (Some(Drive::Windows(id)), parts) => Ok(format!("{}\\{}", id, parts.join("\\"))), + (None, parts) => match &self.basis { + Some(basis) => Ok(format!("{}\\{}", basis, parts.join("\\"))), + None => Err(StrictPathError::Relative), + }, + } + } + + pub fn access_nonwindows(&self) -> Result { + if self.raw.is_empty() { + return Err(StrictPathError::Empty); + } + + match self.analyze() { + (Some(Drive::Root), parts) => Ok(format!("/{}", parts.join("/"))), + (Some(Drive::Windows(_)), _) => Err(StrictPathError::Unsupported), + (None, parts) => match &self.basis { + Some(basis) => Ok(format!("{}/{}", basis, parts.join("/"))), + None => Err(StrictPathError::Relative), + }, + } + } + + pub fn globbable(&self) -> String { + self.display().trim().trim_matches(['/', '\\']).replace('\\', "/") + } + + fn canonical(&self) -> Canonical { + let mut cached = self.canonical.lock().unwrap(); + + match cached.as_ref() { + Some(canonical) => canonical.clone(), + None => match self.access() { + Err(_) => Canonical::Unsupported, + Ok(path) => match std::fs::canonicalize(path) { + Err(_) => Canonical::Inaccessible, + Ok(path) => { + let path = path.to_string_lossy().to_string(); + *cached = Some(Canonical::Valid(path.clone())); + Canonical::Valid(path) + } + }, + }, + } + } + + pub fn interpret(&self) -> String { + match self.canonical() { + Canonical::Valid(path) => match StrictPath::new(path).access() { + Ok(path) => path, + Err(_) => self.display(), + }, + Canonical::Unsupported => self.raw(), + Canonical::Inaccessible => self.display(), } } @@ -385,32 +358,32 @@ impl StrictPath { Self { raw: self.interpret(), basis: self.basis.clone(), - interpreted: Arc::new(Mutex::new(Some(self.interpret()))), + canonical: self.canonical.clone(), } } pub fn render(&self) -> String { - render(self.interpret()) + self.interpreted().display() } pub fn rendered(&self) -> Self { Self { raw: self.render(), basis: self.basis.clone(), - interpreted: Arc::new(Mutex::new(Some(self.interpret()))), + canonical: self.canonical.clone(), } } pub fn resolve(&self) -> String { - if let Ok(resolved) = resolve(&self.raw) { - resolved + if let Ok(access) = self.access() { + access } else { - self.raw.clone() + self.raw() } } pub fn try_resolve(&self) -> Result { - resolve(&self.raw) + self.access() } pub fn is_file(&self) -> bool { @@ -490,11 +463,11 @@ impl StrictPath { } pub fn joined(&self, other: &str) -> Self { - Self::new(format!("{}{}{}", self.interpret(), TYPICAL_SEPARATOR, other)) - } - - pub fn joined_raw(&self, other: &str) -> Self { - Self::new(format!("{}/{}", self.raw(), other)) + Self { + raw: format!("{}/{}", &self.raw, other).replace('\\', "/"), + basis: self.basis.clone(), + canonical: Arc::new(Mutex::new(None)), + } } pub fn create_dirs(&self) -> std::io::Result<()> { @@ -527,7 +500,7 @@ impl StrictPath { } pub fn parent_raw(&self) -> Option { - self.as_std_path_buf_raw().parent().map(Self::from) + std::path::PathBuf::from(&self.raw).parent().map(Self::from) } pub fn leaf(&self) -> Option { @@ -537,8 +510,21 @@ impl StrictPath { } pub fn is_absolute(&self) -> bool { - // TODO: Handle `~` - self.as_std_path_buf_raw().is_absolute() + use std::path::Component; + + if let Some(component) = std::path::PathBuf::from(&self.raw).components().next() { + match component { + Component::Prefix(_) | Component::RootDir => { + return true; + } + Component::CurDir | Component::ParentDir => { + return false; + } + Component::Normal(_) => {} + } + } + + false } pub fn copy_to_path(&self, context: &str, target_file: &StrictPath) -> Result<(), std::io::Error> { @@ -594,56 +580,16 @@ impl StrictPath { } /// This splits a path into a drive (e.g., `C:` or `\\?\D:`) and the remainder. - /// This is only used during backups to record drives in mapping.yaml, so it - /// only has to deal with paths that can occur on the host OS. - #[cfg(target_os = "windows")] + /// This is only used during backups to record drives in mapping.yaml, + /// so relative paths should have already been filtered out. pub fn split_drive(&self) -> (String, String) { - if &self.raw[0..1] == "/" && &self.raw[1..2] != "/" { - // Needed when restoring Linux created backups on Windows - ( - "".to_owned(), - if self.raw.starts_with('/') { - self.raw[1..].to_string() - } else { - self.raw.to_string() - }, - ) - } else { - let interpreted = self.interpret(); - - if let Some(stripped) = interpreted.strip_prefix(UNC_LOCAL_PREFIX) { - // Local UNC path - simplify to a classic drive for user-friendliness: - let split: Vec<_> = stripped.splitn(2, '\\').collect(); - if split.len() == 2 { - return (split[0].to_owned(), split[1].replace('\\', "/")); - } - } else if let Some(stripped) = interpreted.strip_prefix(UNC_PREFIX) { - // Remote UNC path - can't simplify to classic drive: - let split: Vec<_> = stripped.splitn(2, '\\').collect(); - if split.len() == 2 { - return (format!("{}{}", UNC_PREFIX, split[0]), split[1].replace('\\', "/")); - } + match self.analyze() { + (Some(Drive::Root), parts) => ("".to_string(), parts.join("/")), + (Some(Drive::Windows(id)), parts) => (id, parts.join("/")), + (None, _) => { + log::error!("Unreachable state: unable to split drive of path: {}", &self.raw); + unreachable!() } - - // This shouldn't normally happen, but we have a fallback just in case. - ("".to_owned(), self.raw.replace('\\', "/")) - } - } - - #[cfg(not(target_os = "windows"))] - pub fn split_drive(&self) -> (String, String) { - if &self.raw[1..3] == ":/" { - // Needed for the cased that a ZIP was created on Windows but we restore via Linux - (self.raw[0..1].to_owned(), self.raw[3..].to_owned()) - } else { - ( - "".to_owned(), - if self.raw.starts_with('/') { - self.raw[1..].to_string() - } else { - self.raw.to_string() - }, - ) } } @@ -678,36 +624,36 @@ impl StrictPath { Ok(()) } - pub fn is_prefix_of(&self, other: &StrictPath) -> bool { - let us_rendered = splittable(self); - let them_rendered = splittable(other); + pub fn is_prefix_of(&self, other: &Self) -> bool { + let (us_drive, us_parts) = self.analyze(); + let (them_drive, them_parts) = other.analyze(); - let us_components = us_rendered.split('/'); - let them_components = them_rendered.split('/'); + if us_drive != them_drive { + return false; + } - if us_components.clone().count() >= them_components.clone().count() { + if us_parts.len() >= them_parts.len() { return false; } - us_components.zip(them_components).all(|(us, them)| us == them) + + us_parts.iter().zip(them_parts.iter()).all(|(us, them)| us == them) } pub fn nearest_prefix(&self, others: Vec) -> Option { - let us_rendered = splittable(self); - let us_components = us_rendered.split('/'); - let us_count = us_components.clone().count(); + let (us_drive, us_parts) = self.analyze(); + let us_count = us_parts.len(); let mut nearest = None; let mut nearest_len = 0; for other in others { - let them_rendered = splittable(&other); - let them_components = them_rendered.split('/'); - let them_len = them_components.clone().count(); + let (them_drive, them_parts) = other.analyze(); + let them_len = them_parts.len(); - if us_count <= them_len { + if us_drive != them_drive || us_count <= them_len { continue; } - if us_components.clone().zip(them_components).all(|(us, them)| us == them) && them_len > nearest_len { - nearest = Some(other.clone()); + if us_parts.iter().zip(them_parts.iter()).all(|(us, them)| us == them) && them_len > nearest_len { + nearest = Some(other); nearest_len = them_len; } } @@ -849,17 +795,12 @@ impl From<&StrictPath> for StrictPath { } } -// Based on: -// https://github.com/serde-rs/serde/issues/751#issuecomment-277580700 -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -struct StrictPathSerdeHelper(String); - impl serde::Serialize for StrictPath { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { - StrictPathSerdeHelper(self.raw()).serialize(serializer) + self.raw.serialize(serializer) } } @@ -868,7 +809,7 @@ impl<'de> serde::Deserialize<'de> for StrictPath { where D: serde::Deserializer<'de>, { - serde::Deserialize::deserialize(deserializer).map(|StrictPathSerdeHelper(raw)| StrictPath::new(raw)) + serde::Deserialize::deserialize(deserializer).map(StrictPath::new) } } @@ -881,271 +822,17 @@ pub fn is_raw_path_relative(path: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::testing::{repo, repo_raw, s}; - - fn username() -> String { - (*crate::prelude::OS_USERNAME).clone() - } + use crate::testing::{repo, s}; fn home() -> String { CommonPath::Home.get().unwrap().to_string() } - fn drive() -> String { - if cfg!(target_os = "windows") { - StrictPath::new(s("foo")).render()[..2].to_string() - } else { - s("") - } - } - mod strict_path { use pretty_assertions::assert_eq; use super::*; - #[test] - fn can_interpret_general_paths() { - if cfg!(target_os = "windows") { - assert_eq!("".to_string(), interpret("", &Some("/foo".to_string()))); - assert_eq!( - format!(r#"\\?\{}\foo\bar"#, drive()), - interpret("bar", &Some("/foo".to_string())) - ); - } else { - assert_eq!("".to_string(), interpret("", &Some("/foo".to_string()))); - assert_eq!("/foo/bar".to_string(), interpret("bar", &Some("/foo".to_string()))); - } - } - - #[test] - fn can_interpret_linux_style_paths() { - if cfg!(target_os = "windows") { - assert_eq!(format!(r#"\\?\{}\"#, drive()), interpret("/", &None)); - assert_eq!(format!(r#"\\?\{}\foo"#, drive()), interpret("/foo", &None)); - assert_eq!(format!(r#"\\?\{}\foo\bar"#, drive()), interpret("/foo/bar", &None)); - } else { - assert_eq!("/".to_string(), interpret("/", &None)); - assert_eq!("/foo".to_string(), interpret("/foo", &None)); - assert_eq!("/foo/bar".to_string(), interpret("/foo/bar", &None)); - assert_eq!("/foo/bar".to_string(), interpret("/foo/bar/", &None)); - } - } - - #[test] - #[cfg(target_os = "windows")] - fn can_interpret_windows_drive_letter() { - assert_eq!(r#"\\?\C:\foo"#.to_string(), interpret("C:/foo", &None)); - assert_eq!(r#"\\?\C:\"#.to_string(), interpret("C:\\", &None)); - assert_eq!(r#"\\?\C:\"#.to_string(), interpret("C:/", &None)); - assert_eq!(r#"\\?\C:\"#.to_string(), interpret("C:", &None)); - } - - #[test] - #[cfg(target_os = "windows")] - fn can_interpret_unc_path() { - assert_eq!(r#"\\?\C:\foo"#.to_string(), interpret(r#"\\?\C:\foo"#, &None)); - assert_eq!(r#"\\?\C:\"#.to_string(), interpret(r#"\\?\C:\"#, &None)); - assert_eq!(r#"\\?\C:\"#.to_string(), interpret(r#"\\?\C:/"#, &None)); - assert_eq!(r#"\\?\C:\"#.to_string(), interpret(r#"\\?\C:"#, &None)); - } - - #[test] - fn can_render() { - assert_eq!("".to_string(), render("")); - assert_eq!("/".to_string(), render("/")); - assert_eq!("/foo".to_string(), render("/foo")); - assert_eq!("/foo/bar".to_string(), render("/foo/bar")); - assert_eq!("/foo/bar/".to_string(), render("\\foo/bar/")); - assert_eq!("C:/foo".to_string(), render("C:/foo")); - } - - #[test] - fn expands_relative_paths_from_working_dir_by_default() { - let sp = StrictPath::new("README.md".to_owned()); - if cfg!(target_os = "windows") { - assert_eq!(format!("\\\\?\\{}\\README.md", repo_raw()), sp.interpret()); - } else { - assert_eq!(format!("{}/README.md", repo()), sp.interpret()); - } - } - - #[test] - fn expands_relative_paths_from_specified_basis_dir() { - if cfg!(target_os = "windows") { - let sp = StrictPath::relative("README.md".to_owned(), Some("C:\\tmp".to_string())); - assert_eq!("\\\\?\\C:\\tmp\\README.md", sp.interpret()); - } else { - let sp = StrictPath::relative("README.md".to_owned(), Some("/tmp".to_string())); - assert_eq!("/tmp/README.md", sp.interpret()); - } - } - - #[test] - fn converts_single_dot_at_start_of_real_path() { - assert_eq!( - format!("{}/README.md", repo()), - StrictPath::new("./README.md".to_owned()).render(), - ); - } - - #[test] - fn converts_single_dots_at_start_of_real_path() { - assert_eq!( - format!("{}/README.md", repo()), - StrictPath::new("./././README.md".to_owned()).render(), - ); - } - - #[test] - fn converts_single_dot_at_start_of_fake_path() { - assert_eq!( - format!("{}/fake/README.md", repo()), - StrictPath::relative("./README.md".to_owned(), Some(format!("{}/fake", repo()))).render(), - ); - } - - #[test] - fn converts_single_dot_within_real_path() { - assert_eq!( - format!("{}/README.md", repo()), - StrictPath::new(format!("{}/./README.md", repo())).render(), - ); - } - - #[test] - fn converts_single_dots_within_real_path() { - assert_eq!( - format!("{}/README.md", repo()), - StrictPath::new(format!("{}/./././README.md", repo())).render(), - ); - } - - #[test] - fn converts_single_dot_within_fake_path() { - assert_eq!( - format!("{}/fake/README.md", repo()), - StrictPath::new(format!("{}/fake/./README.md", repo())).render(), - ); - } - - #[test] - fn converts_double_dots_at_start_of_real_path() { - assert_eq!( - format!("{}/README.md", repo()), - StrictPath::relative("../README.md".to_owned(), Some(format!("{}/src", repo()))).render(), - ); - } - - #[test] - fn converts_double_dots_at_start_of_fake_path() { - assert_eq!( - format!("{}/fake.md", repo()), - StrictPath::relative("../fake.md".to_owned(), Some(format!("{}/fake", repo()))).render(), - ); - } - - #[test] - fn converts_double_dots_within_real_path() { - assert_eq!( - format!("{}/README.md", repo()), - StrictPath::new(format!("{}/src/../README.md", repo())).render(), - ); - } - - #[test] - fn converts_double_dots_within_fake_path() { - assert_eq!( - format!("{}/fake.md", repo()), - StrictPath::new(format!("{}/fake/../fake.md", repo())).render(), - ); - } - - #[test] - fn treats_absolute_paths_as_such() { - if cfg!(target_os = "windows") { - let sp = StrictPath::new("C:\\tmp\\README.md".to_owned()); - assert_eq!("\\\\?\\C:\\tmp\\README.md", sp.interpret()); - } else { - let sp = StrictPath::new("/tmp/README.md".to_owned()); - assert_eq!("/tmp/README.md", sp.interpret()); - } - } - - #[test] - fn converts_tilde_in_isolation() { - let sp = StrictPath::new("~".to_owned()); - if cfg!(target_os = "windows") { - assert_eq!(format!("\\\\?\\C:\\Users\\{}", username()), sp.interpret()); - assert_eq!(format!("C:/Users/{}", username()), sp.render()); - } else { - assert_eq!(home(), sp.interpret()); - assert_eq!(home(), sp.render()); - } - } - - #[test] - fn converts_tilde_before_forward_slash() { - let sp = StrictPath::new("~/~".to_owned()); - if cfg!(target_os = "windows") { - assert_eq!(format!("\\\\?\\C:\\Users\\{}\\~", username()), sp.interpret()); - assert_eq!(format!("C:/Users/{}/~", username()), sp.render()); - } else { - assert_eq!(format!("{}/~", home()), sp.interpret()); - assert_eq!(format!("{}/~", home()), sp.render()); - } - } - - #[test] - fn converts_tilde_before_backslash() { - let sp = StrictPath::new("~\\~".to_owned()); - if cfg!(target_os = "windows") { - assert_eq!(format!("\\\\?\\C:\\Users\\{}\\~", username()), sp.interpret()); - assert_eq!(format!("C:/Users/{}/~", username()), sp.render()); - } else { - assert_eq!(format!("{}/~", home()), sp.interpret()); - assert_eq!(format!("{}/~", home()), sp.render()); - } - } - - #[test] - fn does_not_convert_tilde_before_a_nonslash_character() { - let sp = StrictPath::new("~a".to_owned()); - if cfg!(target_os = "windows") { - assert_eq!(format!("\\\\?\\{}\\~a", repo_raw()), sp.interpret()); - } else { - assert_eq!(format!("{}/~a", repo()), sp.interpret()); - } - } - - #[test] - #[cfg(target_os = "windows")] - fn does_not_truncate_path_up_to_drive_letter_in_classic_path() { - // https://github.com/mtkennerly/ludusavi/issues/36 - // Test for: / - - let sp = StrictPath::relative( - "C:\\Users\\Foo\\Documents/C:\\Users\\Bar".to_string(), - Some("\\\\?\\C:\\Users\\Foo\\.config\\ludusavi".to_string()), - ); - assert_eq!(r#"\\?\C:\Users\Foo\Documents\C_\Users\Bar"#, sp.interpret()); - assert_eq!("C:/Users/Foo/Documents/C_/Users/Bar", sp.render()); - } - - #[test] - #[cfg(target_os = "windows")] - fn does_not_truncate_path_up_to_drive_letter_in_unc_path() { - // https://github.com/mtkennerly/ludusavi/issues/36 - // Test for: / - - let sp = StrictPath::relative( - "\\\\?\\C:\\Users\\Foo\\Documents\\C:\\Users\\Bar".to_string(), - Some("\\\\?\\C:\\Users\\Foo\\.config\\ludusavi".to_string()), - ); - assert_eq!(r#"\\?\C:\Users\Foo\Documents\C_\Users\Bar"#, sp.interpret()); - assert_eq!("C:/Users/Foo/Documents/C_/Users/Bar", sp.render()); - } - #[test] fn can_check_if_it_is_a_file() { assert!(StrictPath::new(format!("{}/README.md", repo())).is_file()); @@ -1171,28 +858,6 @@ mod tests { assert_eq!((s("C:"), s("foo/bar")), StrictPath::new(s("C:/foo/bar")).split_drive()); } - #[test] - #[cfg(target_os = "windows")] - fn can_split_drive_for_local_unc_path() { - assert_eq!( - (s("C:"), s("foo/bar")), - StrictPath::new(s(r#"\\?\C:\foo\bar"#)).split_drive() - ); - } - - #[test] - #[cfg(target_os = "windows")] - fn can_split_drive_for_remote_unc_path() { - // TODO: Should be `\\remote` and `foo\bar`. - // Despite this, when backing up to a machine-local network share, - // it gets resolved to the actual local drive and therefore works. - // Unsure about behavior for a remote network share at this time. - assert_eq!( - (s(""), s("/remote/foo/bar")), - StrictPath::new(s(r#"\\remote\foo\bar"#)).split_drive() - ); - } - #[test] #[cfg(not(target_os = "windows"))] fn can_split_drive_for_nonwindows_path() { @@ -1291,4 +956,251 @@ mod tests { .is_err()); } } + + mod strict_path_display_and_access { + use super::*; + + use pretty_assertions::assert_eq; + + fn analysis(drive: Drive) -> (Option, Vec) { + (Some(drive), vec!["foo".to_string(), "bar".to_string()]) + } + + #[test] + fn linux_style() { + let path = StrictPath::from("/foo/bar"); + + assert_eq!(analysis(Drive::Root), path.analyze()); + assert_eq!("/foo/bar", path.display()); + assert_eq!(Ok(r"C:\foo\bar".to_string()), path.access_windows()); + assert_eq!(Ok("/foo/bar".to_string()), path.access_nonwindows()); + } + + #[test] + fn windows_style_verbatim() { + let path = StrictPath::from(r"\\?\share\foo\bar"); + + assert_eq!(analysis(Drive::Windows(r"\\?\share".to_string())), path.analyze()); + assert_eq!(r"\\?\share/foo/bar", path.display()); + assert_eq!(Ok(r"\\?\share\foo\bar".to_string()), path.access_windows()); + assert_eq!(Err(StrictPathError::Unsupported), path.access_nonwindows()); + } + + #[test] + fn windows_style_verbatim_unc() { + let path = StrictPath::from(r"\\?\UNC\server\share\foo\bar"); + + assert_eq!( + analysis(Drive::Windows(r"\\?\UNC\server\share".to_string())), + path.analyze() + ); + assert_eq!(r"\\?\UNC\server\share/foo/bar", path.display()); + assert_eq!(Ok(r"\\?\UNC\server\share\foo\bar".to_string()), path.access_windows()); + assert_eq!(Err(StrictPathError::Unsupported), path.access_nonwindows()); + } + + #[test] + fn windows_style_verbatim_disk() { + let path = StrictPath::from(r"\\?\C:\foo\bar"); + + assert_eq!(analysis(Drive::Windows(r"C:".to_string())), path.analyze()); + assert_eq!(r"C:/foo/bar", path.display()); + assert_eq!(Ok(r"C:\foo\bar".to_string()), path.access_windows()); + assert_eq!(Err(StrictPathError::Unsupported), path.access_nonwindows()); + } + + #[test] + fn windows_style_device_ns() { + let path = StrictPath::from(r"\\.\COM42\foo\bar"); + + assert_eq!(analysis(Drive::Windows(r"\\.\COM42".to_string())), path.analyze()); + assert_eq!(r"\\.\COM42/foo/bar", path.display()); + assert_eq!(Ok(r"\\.\COM42\foo\bar".to_string()), path.access_windows()); + assert_eq!(Err(StrictPathError::Unsupported), path.access_nonwindows()); + } + + #[test] + fn windows_style_unc() { + let path = StrictPath::from(r"\\server\share\foo\bar"); + + assert_eq!(analysis(Drive::Windows(r"\\server\share".to_string())), path.analyze()); + assert_eq!(r"\\server\share/foo/bar", path.display()); + assert_eq!(Ok(r"\\server\share\foo\bar".to_string()), path.access_windows()); + assert_eq!(Err(StrictPathError::Unsupported), path.access_nonwindows()); + } + + #[test] + fn windows_style_disk() { + let path = StrictPath::from(r"C:\foo\bar"); + + assert_eq!(analysis(Drive::Windows(r"C:".to_string())), path.analyze()); + assert_eq!(r"C:/foo/bar", path.display()); + assert_eq!(Ok(r"C:\foo\bar".to_string()), path.access_windows()); + assert_eq!(Err(StrictPathError::Unsupported), path.access_nonwindows()); + } + + #[test] + fn relative_plain() { + let path = StrictPath::from("foo"); + assert_eq!((None, vec!["foo".to_string()]), path.analyze()); + assert_eq!("foo".to_string(), path.display()); + assert_eq!(Err(StrictPathError::Relative), path.access_windows()); + assert_eq!(Err(StrictPathError::Relative), path.access_nonwindows()); + + let path = StrictPath::relative("foo".to_string(), Some("/tmp".to_string())); + assert_eq!( + (Some(Drive::Root), vec!["tmp".to_string(), "foo".to_string()]), + path.analyze() + ); + assert_eq!("/tmp/foo".to_string(), path.display()); + assert_eq!(Ok(r"C:\tmp\foo".to_string()), path.access_windows()); + assert_eq!(Ok("/tmp/foo".to_string()), path.access_nonwindows()); + } + + #[test] + fn relative_single_dot() { + let path = StrictPath::from("./foo"); + assert_eq!((None, vec!["foo".to_string()]), path.analyze()); + assert_eq!("foo".to_string(), path.display()); + assert_eq!(Err(StrictPathError::Relative), path.access_windows()); + assert_eq!(Err(StrictPathError::Relative), path.access_nonwindows()); + + let path = StrictPath::relative("./foo".to_string(), Some("/tmp".to_string())); + assert_eq!( + (Some(Drive::Root), vec!["tmp".to_string(), "foo".to_string()]), + path.analyze() + ); + assert_eq!("/tmp/foo".to_string(), path.display()); + assert_eq!(Ok(r"C:\tmp\foo".to_string()), path.access_windows()); + assert_eq!(Ok("/tmp/foo".to_string()), path.access_nonwindows()); + } + + #[test] + fn relative_double_dot() { + let path = StrictPath::from("../foo"); + assert_eq!((None, vec!["foo".to_string()]), path.analyze()); + assert_eq!("foo".to_string(), path.display()); + assert_eq!(Err(StrictPathError::Relative), path.access_windows()); + assert_eq!(Err(StrictPathError::Relative), path.access_nonwindows()); + + let path = StrictPath::relative("../foo".to_string(), Some("/tmp/bar".to_string())); + assert_eq!( + (Some(Drive::Root), vec!["tmp".to_string(), "foo".to_string()]), + path.analyze() + ); + assert_eq!("/tmp/foo".to_string(), path.display()); + assert_eq!(Ok(r"C:\tmp\foo".to_string()), path.access_windows()); + assert_eq!(Ok("/tmp/foo".to_string()), path.access_nonwindows()); + } + + #[test] + fn tilde() { + let path = StrictPath::new("~".to_owned()); + assert_eq!(Ok(home()), path.access()); + } + + #[test] + fn empty() { + let path = StrictPath::from(""); + assert_eq!((None, vec![]), path.analyze()); + assert_eq!("".to_string(), path.display()); + assert_eq!(Err(StrictPathError::Empty), path.access_windows()); + assert_eq!(Err(StrictPathError::Empty), path.access_nonwindows()); + } + + #[test] + fn extra_slashes() { + let path = StrictPath::from(r"//foo\\bar/\baz"); + assert_eq!( + ( + Some(Drive::Root), + vec!["foo".to_string(), "bar".to_string(), "baz".to_string()] + ), + path.analyze() + ); + } + + #[test] + fn linux_root_variations() { + let path = StrictPath::from("/"); + + assert_eq!((Some(Drive::Root), vec![]), path.analyze()); + assert_eq!("/", path.display()); + assert_eq!(Ok(r"C:\".to_string()), path.access_windows()); + assert_eq!(Ok("/".to_string()), path.access_nonwindows()); + + let path = StrictPath::from(r"\"); + + assert_eq!((Some(Drive::Root), vec![]), path.analyze()); + assert_eq!("/", path.display()); + assert_eq!(Ok(r"C:\".to_string()), path.access_windows()); + assert_eq!(Ok("/".to_string()), path.access_nonwindows()); + } + + #[test] + fn windows_root_variations() { + macro_rules! check { + ($input:expr, $output:expr) => { + let path = StrictPath::from($input); + assert_eq!( + (Some(Drive::Windows($output.to_string())), vec![]), + path.analyze() + ); + }; + } + + // Verbatim + check!(r"\\?\share", r"\\?\share"); + check!(r"//?/share", r"\\?\share"); + + // Verbatim UNC + check!(r"\\?\UNC\server\share", r"\\?\UNC\server\share"); + // check!(r"//?/UNC/server/share", r"\\?\UNC\server\share"); + + // Verbatim disk + check!(r"\\?\C:", r"C:"); + check!(r"\\?\C:\", r"C:"); + check!(r"//?/C:", r"C:"); + check!(r"//?/C:/", r"C:"); + + // Device NS + check!(r"\\.\COM42", r"\\.\COM42"); + check!(r"//./COM42", r"\\.\COM42"); + + // UNC + check!(r"\\server\share", r"\\server\share"); + check!(r"//server/share", r"\\server\share"); + + // Disk + check!(r"C:", r"C:"); + check!(r"C:\", r"C:"); + check!(r"C:/", r"C:"); + } + + #[test] + fn does_not_truncate_path_up_to_drive_letter_in_windows_classic_path() { + // https://github.com/mtkennerly/ludusavi/issues/36 + // Test for: / + + let path = StrictPath::relative( + r"C:\Users\Foo\Documents/C:\Users\Bar".to_string(), + Some(r"\\?\C:\Users\Foo\.config\ludusavi".to_string()), + ); + assert_eq!("C:/Users/Foo/Documents/C_/Users/Bar", path.interpret()); + assert_eq!("C:/Users/Foo/Documents/C_/Users/Bar", path.render()); + } + + #[test] + fn does_not_truncate_path_up_to_drive_letter_in_windows_unc_path() { + // https://github.com/mtkennerly/ludusavi/issues/36 + // Test for: / + + let path = StrictPath::relative( + r"\\?\C:\Users\Foo\Documents\C:\Users\Bar".to_string(), + Some(r"\\?\C:\Users\Foo\.config\ludusavi".to_string()), + ); + assert_eq!("C:/Users/Foo/Documents/C_/Users/Bar", path.interpret()); + assert_eq!("C:/Users/Foo/Documents/C_/Users/Bar", path.render()); + } + } } diff --git a/src/resource/config.rs b/src/resource/config.rs index d537da0..30cfa87 100644 --- a/src/resource/config.rs +++ b/src/resource/config.rs @@ -300,12 +300,11 @@ impl BackupFilter { let mut builder = globset::GlobSetBuilder::new(); for item in &self.ignored_paths { - let normalized = crate::path::parse_home(&item.raw()).replace('\\', "/"); - let normalized = normalized.trim_end_matches('/'); + let normalized = item.globbable(); let variants = vec![ normalized.to_string(), - // If the user has specified a plain folder, we also want to ignore its children. + // If the user has specified a plain folder, we also want to include its children. format!("{}/**", &normalized), ]; diff --git a/src/scan/launchers/lutris.rs b/src/scan/launchers/lutris.rs index 7a36a7d..622ecbc 100644 --- a/src/scan/launchers/lutris.rs +++ b/src/scan/launchers/lutris.rs @@ -93,7 +93,7 @@ fn scan_spec(spec: LutrisGame, spec_path: &StrictPath, title_finder: &TitleFinde let exe = if exe.is_absolute() { exe } else if let Some(prefix) = &prefix { - prefix.joined_raw(&exe.raw()) + prefix.joined(&exe.raw()) } else { log::info!( "Skipping Lutris game file with relative exe and no prefix: {}", diff --git a/src/scan/layout.rs b/src/scan/layout.rs index 6b5faed..a2a5316 100644 --- a/src/scan/layout.rs +++ b/src/scan/layout.rs @@ -2095,6 +2095,8 @@ mod tests { mod backup_layout { use pretty_assertions::assert_eq; + use crate::testing::{repo_file_raw, repo_path, repo_path_raw}; + use super::*; fn layout() -> BackupLayout { @@ -2113,31 +2115,20 @@ mod tests { } fn drives() -> HashMap { - let (drive, _) = StrictPath::new("foo".to_string()).split_drive(); + let (drive, _) = StrictPath::cwd().split_drive(); let folder = IndividualMapping::new_drive_folder_name(&drive); hash_map! { folder: drive } } #[test] fn can_find_existing_game_folder_with_matching_name() { - assert_eq!( - StrictPath::new(if cfg!(target_os = "windows") { - format!("\\\\?\\{}\\tests\\backup\\game1", repo_raw()) - } else { - format!("{}/tests/backup/game1", repo_raw()) - }), - layout().game_folder("game1") - ); + assert_eq!(repo_path_raw("tests/backup/game1"), layout().game_folder("game1")); } #[test] fn can_find_existing_game_folder_with_rename() { assert_eq!( - StrictPath::new(if cfg!(target_os = "windows") { - format!("\\\\?\\{}\\tests\\backup\\game3-renamed", repo_raw()) - } else { - format!("{}/tests/backup/game3-renamed", repo_raw()) - }), + repo_path_raw("tests/backup/game3-renamed"), layout().game_folder("game3") ); } @@ -2145,77 +2136,43 @@ mod tests { #[test] fn can_determine_game_folder_that_does_not_exist_without_rename() { assert_eq!( - if cfg!(target_os = "windows") { - StrictPath::new(format!("\\\\?\\{}\\tests\\backup\\nonexistent", repo_raw())) - } else { - StrictPath::new(format!("{}/tests/backup/nonexistent", repo_raw())) - }, + repo_path("tests/backup/nonexistent"), layout().game_folder("nonexistent") ); } #[test] fn can_determine_game_folder_that_does_not_exist_with_partial_rename() { - assert_eq!( - if cfg!(target_os = "windows") { - StrictPath::new(format!("\\\\?\\{}\\tests\\backup\\foo_bar", repo_raw())) - } else { - StrictPath::new(format!("{}/tests/backup/foo_bar", repo_raw())) - }, - layout().game_folder("foo:bar") - ); + assert_eq!(repo_path("tests/backup/foo_bar"), layout().game_folder("foo:bar")); } #[test] fn can_determine_game_folder_that_does_not_exist_with_total_rename() { assert_eq!( - if cfg!(target_os = "windows") { - StrictPath::new(format!("\\\\?\\{}\\tests\\backup\\ludusavi-renamed-Kioq", repo_raw())) - } else { - StrictPath::new(format!("{}/tests/backup/ludusavi-renamed-Kioq", repo_raw())) - }, + repo_path("tests/backup/ludusavi-renamed-Kioq"), layout().game_folder("***") ); } #[test] fn can_determine_game_folder_by_escaping_dots_at_start_and_end() { - assert_eq!( - if cfg!(target_os = "windows") { - StrictPath::new(format!("\\\\?\\{}\\tests\\backup\\_._", repo_raw())) - } else { - StrictPath::new(format!("{}/tests/backup/_._", repo_raw())) - }, - layout().game_folder("...") - ); + assert_eq!(repo_path("tests/backup/_._"), layout().game_folder("...")); } #[test] fn can_find_irrelevant_backup_files() { assert_eq!( - vec![if cfg!(target_os = "windows") { - StrictPath::new(format!( - "\\\\?\\{}\\tests\\backup\\game1\\drive-X\\file2.txt", - repo_raw() - )) - } else { - StrictPath::new(format!("{}/tests/backup/game1/drive-X/file2.txt", repo_raw())) - }], - game_layout("game1", &format!("{}/tests/backup/game1", repo_raw())).find_irrelevant_backup_files( - ".", - &[StrictPath::new(format!( - "{}/tests/backup/game1/drive-X/file1.txt", - repo_raw() - ))] - ) + vec![repo_path_raw("tests/backup/game1/drive-X/file2.txt")], + game_layout("game1", &repo_file_raw("tests/backup/game1")) + .find_irrelevant_backup_files(".", &[repo_path("tests/backup/game1/drive-X/file1.txt")]) ); assert_eq!( Vec::::new(), - game_layout("game1", &format!("{}/tests/backup/game1", repo_raw())).find_irrelevant_backup_files( + game_layout("game1", &repo_file("tests/backup/game1")).find_irrelevant_backup_files( ".", &[ - StrictPath::new(format!("{}/tests/backup/game1/drive-X/file1.txt", repo_raw())), - StrictPath::new(format!("{}/tests/backup/game1/drive-X/file2.txt", repo_raw())), + repo_path("tests/backup/game1/drive-X/file1.txt"), + repo_path("tests/backup/game1/drive-X/file2.txt"), ] ) ); @@ -2924,15 +2881,7 @@ mod tests { } fn make_path(file: &str) -> StrictPath { - StrictPath::new(if cfg!(target_os = "windows") { - format!( - "\\\\?\\{}\\tests\\backup\\game1\\{}", - repo_raw().replace('/', "\\"), - file.replace('/', "\\") - ) - } else { - format!("{}/tests/backup/game1/{}", repo_raw(), file) - }) + repo_path(&format!("tests/backup/game1/{}", file)) } fn make_restorable_path(backup: &str, file: &str) -> StrictPath { @@ -2941,11 +2890,7 @@ mod tests { "{backup}/drive-{}/{file}", if cfg!(target_os = "windows") { "X" } else { "0" } ), - Some(if cfg!(target_os = "windows") { - format!("\\\\?\\{}\\tests\\backup\\game1", repo_raw().replace('/', "\\")) - } else { - format!("{}/tests/backup/game1", repo_raw()) - }), + Some(repo_file_raw("tests/backup/game1")), ) } @@ -3202,7 +3147,7 @@ mod tests { mod game_layout { use pretty_assertions::assert_eq; - use crate::testing::drives_x_always; + use crate::testing::{drives_x_always, repo_file_raw}; use super::*; @@ -3221,11 +3166,7 @@ mod tests { "{backup}/drive-{}/{file}", if cfg!(target_os = "windows") { "X" } else { "0" } ), - Some(if cfg!(target_os = "windows") { - format!("\\\\?\\{}\\tests\\backup\\game1", repo().replace('/', "\\")) - } else { - format!("{}/tests/backup/game1", repo()) - }), + Some(repo_file_raw("tests/backup/game1")), ) } diff --git a/src/testing.rs b/src/testing.rs index 0b3869d..dcd7c1b 100644 --- a/src/testing.rs +++ b/src/testing.rs @@ -14,6 +14,26 @@ pub fn repo_raw() -> String { env!("CARGO_MANIFEST_DIR").to_string() } +pub fn repo_file(path: &str) -> String { + repo_file_raw(path).replace('\\', "/") +} + +pub fn repo_file_raw(path: &str) -> String { + if cfg!(target_os = "windows") { + format!("{}\\{}", repo_raw(), path.replace('/', "\\")) + } else { + format!("{}/{}", repo_raw(), path) + } +} + +pub fn repo_path(path: &str) -> StrictPath { + StrictPath::new(repo_file(path)) +} + +pub fn repo_path_raw(path: &str) -> StrictPath { + StrictPath::new(repo_file_raw(path)) +} + pub fn absolute_path(file: &str) -> StrictPath { if cfg!(target_os = "windows") { StrictPath::new(format!("X:{file}"))