use crate::{format::CodePath, path_util::relative_path, wiki::FilesystemTarget};
use colored::ColoredString;
use std::{
collections::{HashMap, HashSet},
ffi::{OsStr, OsString},
fs, io,
path::{Path, PathBuf},
rc::Rc,
};
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SpelledPath(PathBuf);
impl SpelledPath {
pub fn as_path(&self) -> &Path {
&self.0
}
pub fn is_wiki_directory(&self) -> bool {
self.0.as_os_str().is_empty()
}
pub fn starts_with(&self, other: &SpelledPath) -> bool {
self.0.starts_with(&other.0)
}
pub fn parent(&self) -> Option<SpelledPath> {
self.0.parent().map(|parent| SpelledPath(parent.to_owned()))
}
pub fn file_name(&self) -> Option<&OsStr> {
self.0.file_name()
}
fn spell(
wiki_directory: &Path,
path: &Path,
listings: &mut DirectoryListings,
) -> Result<Self, SpellingError> {
let mut spelled = PathBuf::new();
for component in path.components() {
let name = component.as_os_str();
let directory = wiki_directory.join(&spelled);
let names = match listings.entry(directory.clone()).or_insert_with(|| {
fs::read_dir(&directory)
.map(|entries| {
entries
.flatten()
.map(|entry| fs::DirEntry::file_name(&entry))
.collect()
})
.map_err(Rc::new)
}) {
Ok(names) => names,
Err(error) => {
return Err(SpellingError {
message: format!(
"Unable to list {}, so the spelling of {} can't be checked.",
if spelled.as_os_str().is_empty() {
"the wiki directory".to_owned()
} else {
spelled.code_path().to_string()
},
path.code_path(),
),
reason: Some(error.clone()),
});
}
};
spelled.push(name);
if !names.contains(name) {
return Err(SpellingError {
message: format!(
"{} doesn't match the spelling of any name on disk.",
spelled.code_path(),
),
reason: None,
});
}
}
Ok(SpelledPath(spelled))
}
}
impl CodePath for SpelledPath {
fn code_path(&self) -> ColoredString {
self.0.code_path()
}
}
#[derive(Debug)]
pub struct SpellingError {
pub message: String,
pub reason: Option<Rc<io::Error>>,
}
#[derive(Clone, Debug)]
pub struct WikiDirectory {
path: PathBuf,
wiki_path: Option<SpelledPath>,
}
impl WikiDirectory {
pub fn new(wiki_path: &Path) -> Result<Self, SpellingError> {
let path = wiki_path
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
.to_owned();
let spelled_wiki_path = match SpelledPath::spell(
&path,
relative_path(&path, wiki_path),
&mut DirectoryListings::new(),
) {
Ok(spelled) => Some(spelled),
Err(_) if fs::symlink_metadata(wiki_path).is_err() => None,
Err(error) => return Err(error),
};
Ok(Self {
path,
wiki_path: spelled_wiki_path,
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn wiki_path(&self) -> Option<&SpelledPath> {
self.wiki_path.as_ref()
}
pub fn resolve(&self, path: &SpelledPath) -> PathBuf {
self.path.join(&path.0)
}
pub fn entry_path(&self, entry: &ignore::DirEntry) -> SpelledPath {
SpelledPath(
entry
.path()
.strip_prefix(&self.path)
.expect("A walk of the wiki directory should only find entries within it.")
.to_owned(),
)
}
pub fn spell(
&self,
target: &FilesystemTarget,
listings: &mut DirectoryListings,
) -> Result<SpelledPath, SpellingError> {
SpelledPath::spell(&self.path, target.path(), listings)
}
pub fn spell_existing_ancestor(
&self,
target: &FilesystemTarget,
) -> Result<(SpelledPath, PathBuf), SpellingError> {
let path = target.path();
let mut ancestor = Path::new("");
for candidate in path.ancestors().skip(1) {
match self.path.join(candidate).try_exists() {
Ok(true) => {
ancestor = candidate;
break;
}
Ok(false) => {}
Err(error) => {
return Err(SpellingError {
message: format!(
"Unable to access {}.",
if candidate.as_os_str().is_empty() {
"the wiki directory".to_owned()
} else {
candidate.code_path().to_string()
},
),
reason: Some(Rc::new(error)),
});
}
}
}
Ok((
SpelledPath::spell(&self.path, ancestor, &mut DirectoryListings::new())?,
path.strip_prefix(ancestor)
.expect("An ancestor of a path should be a prefix of it.")
.to_owned(),
))
}
}
pub type DirectoryListings = HashMap<PathBuf, Result<HashSet<OsString>, Rc<io::Error>>>;
#[cfg(unix)]
pub fn entry_identity(path: &Path) -> Option<(u64, u64)> {
use std::os::unix::fs::MetadataExt;
fs::symlink_metadata(path)
.ok()
.map(|metadata| (metadata.dev(), metadata.ino()))
}
#[cfg(not(unix))]
pub fn entry_identity(path: &Path) -> Option<PathBuf> {
fs::canonicalize(path).ok()
}
#[cfg(test)]
mod tests {
use super::{DirectoryListings, WikiDirectory};
use crate::wiki::{ContentText, FilesystemTarget};
use std::{env, fs, process};
#[cfg(unix)]
#[test]
fn inaccessible_ancestor() {
use std::os::unix::fs::PermissionsExt;
let directory = env::temp_dir().join(format!("mull-access-{}", process::id()));
let locked = directory.join("locked");
fs::create_dir_all(&locked).unwrap();
fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
let wiki_directory = WikiDirectory::new(&directory.join("wiki.mull")).unwrap();
let result = wiki_directory.spell_existing_ancestor(
&FilesystemTarget::parse(&ContentText::from_source("/locked/inner/file.txt")).unwrap(),
);
let accessible = locked.join("inner").try_exists().is_ok();
fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).unwrap();
fs::remove_dir_all(&directory).unwrap();
if !accessible {
let inner_path = std::path::Path::new("locked").join("inner");
assert_eq!(
result.unwrap_err().message,
format!("Unable to access `{}`.", inner_path.display()),
);
}
}
#[test]
fn missing_component() {
let directory = env::temp_dir().join(format!("mull-spelling-{}", process::id()));
fs::create_dir_all(&directory).unwrap();
let wiki_directory = WikiDirectory::new(&directory.join("wiki.mull")).unwrap();
let error = wiki_directory
.spell(
&FilesystemTarget::parse(&ContentText::from_source("/missing/file.txt")).unwrap(),
&mut DirectoryListings::new(),
)
.unwrap_err();
fs::remove_dir_all(&directory).unwrap();
assert_eq!(
error.message,
"`missing` doesn't match the spelling of any name on disk.",
);
}
}