use crate::{
error::Error,
format::{CodePath, CodeStr},
path_util::relative_path,
wiki::{HOME_TITLE, Link, Wiki},
};
use ignore::{WalkBuilder, overrides::OverrideBuilder};
use std::{
collections::HashSet,
fs,
path::{Path, PathBuf},
rc::Rc,
};
const MAX_FILESYSTEM_ERRORS: usize = 50;
pub fn validate(
wiki: &Wiki,
wiki_path: &Path,
source_path: &Path,
source_contents: &str,
) -> Result<(), Vec<Error>> {
let mut errors = validate_text_links(wiki, source_path, source_contents);
let original_wiki_directory = wiki_path
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let wiki_directory = match fs::canonicalize(original_wiki_directory) {
Ok(wiki_directory) => wiki_directory,
Err(error) => {
errors.push(Error::new(
&format!(
"Failed to resolve wiki directory {}.",
original_wiki_directory.code_path(),
),
Some(source_path),
None,
Some(Rc::new(error)),
));
return errors_to_result(errors);
}
};
if let Err(error) = fs::metadata(wiki_path) {
errors.push(Error::new(
&format!("Failed to resolve {}.", wiki_path.code_path()),
Some(source_path),
None,
Some(Rc::new(error)),
));
return errors_to_result(errors);
}
let Some(wiki_file_name) = wiki_path.file_name() else {
errors.push(Error::new(
&format!(
"Failed to determine the file name of {}.",
wiki_path.code_path(),
),
Some(source_path),
None,
None,
));
return errors_to_result(errors);
};
let logical_wiki_path = wiki_directory.join(wiki_file_name);
errors.extend(validate_filesystem_links(
wiki,
&wiki_directory,
&logical_wiki_path,
source_path,
source_contents,
));
errors_to_result(errors)
}
fn validate_text_links(wiki: &Wiki, source_path: &Path, source_contents: &str) -> Vec<Error> {
let mut errors = Vec::<Error>::new();
let has_home = wiki.text_nodes.contains_key(HOME_TITLE);
if !has_home {
errors.push(Error::new(
&format!("Wiki does not contain a {} node.", HOME_TITLE.code_str()),
Some(source_path),
None,
None,
));
}
let mut nodes = wiki.text_nodes.values().collect::<Vec<_>>();
nodes.sort_by_key(|node| &node.title);
for node in &nodes {
for link in &node.links {
if let Link::Text {
title,
source_range,
} = link
&& !wiki.text_nodes.contains_key(title)
{
let message = if title.is_empty() {
"Link target is empty.".to_owned()
} else {
format!("Node {} not found.", title.code_str())
};
errors.push(Error::new(
&message,
Some(source_path),
Some((source_contents, *source_range)),
None,
));
}
}
}
if has_home {
let mut unreachable_titles = wiki
.text_nodes
.values()
.filter(|node| node.depth.is_none())
.map(|node| (&node.title, node.title_source_range))
.collect::<Vec<_>>();
unreachable_titles.sort_by_key(|(title, _source_range)| *title);
errors.extend(unreachable_titles.into_iter().map(|(title, source_range)| {
Error::new(
&format!(
"Node {} is not reachable from {}.",
title.code_str(),
HOME_TITLE.code_str(),
),
Some(source_path),
Some((source_contents, source_range)),
None,
)
}));
}
errors
}
fn validate_filesystem_links(
wiki: &Wiki,
wiki_directory: &Path,
wiki_path: &Path,
source_path: &Path,
source_contents: &str,
) -> Vec<Error> {
let mut referenced_files = HashSet::<PathBuf>::new();
let mut referenced_directories = HashSet::<PathBuf>::new();
let mut errors = Vec::<Error>::new();
let mut nodes = wiki.text_nodes.values().collect::<Vec<_>>();
nodes.sort_by_key(|node| &node.title);
'nodes: for node in nodes {
for link in &node.links {
let (path, source_range) = match link {
Link::Text { .. } => continue,
Link::File { path, source_range } | Link::Directory { path, source_range } => {
(path, *source_range)
}
};
let target = wiki_directory.join(path);
let metadata = match fs::metadata(&target) {
Ok(metadata) => metadata,
Err(error) => {
let message = if error.kind() == std::io::ErrorKind::NotFound {
format!("{} not found.", path.code_path())
} else {
format!("Failed to access {}.", path.code_path())
};
errors.push(Error::new(
&message,
Some(source_path),
Some((source_contents, source_range)),
Some(Rc::new(error)),
));
if errors.len() >= MAX_FILESYSTEM_ERRORS {
break 'nodes;
}
continue;
}
};
match link {
Link::File { .. } if metadata.is_file() => {
referenced_files.insert(target);
}
Link::Directory { .. } if metadata.is_dir() => {
referenced_directories.insert(target);
}
Link::File { .. } => errors.push(Error::new(
&format!("{} is not a file.", path.code_path()),
Some(source_path),
Some((source_contents, source_range)),
None,
)),
Link::Directory { .. } => errors.push(Error::new(
&format!("{} is not a directory.", path.code_path()),
Some(source_path),
Some((source_contents, source_range)),
None,
)),
Link::Text { .. } => {
unreachable!("text links were already skipped")
}
}
if errors.len() >= MAX_FILESYSTEM_ERRORS {
break 'nodes;
}
}
}
if errors.len() >= MAX_FILESYSTEM_ERRORS {
return errors;
}
let remaining_error_capacity = MAX_FILESYSTEM_ERRORS - errors.len();
errors.extend(find_unreferenced_filesystem_links(
wiki_directory,
wiki_path,
&referenced_files,
&referenced_directories,
remaining_error_capacity,
source_path,
));
errors
}
fn find_unreferenced_filesystem_links(
wiki_directory: &Path,
wiki_path: &Path,
referenced_files: &HashSet<PathBuf>,
referenced_directories: &HashSet<PathBuf>,
maximum_errors: usize,
source_path: &Path,
) -> Vec<Error> {
if referenced_directories.contains(wiki_directory) {
return Vec::new();
}
let mut overrides = OverrideBuilder::new(wiki_directory);
overrides
.add("!.git/")
.expect("the static .git override should be valid")
.add("!.hg/")
.expect("the static .hg override should be valid");
let overrides = match overrides.build() {
Ok(overrides) => overrides,
Err(error) => {
return vec![Error::new(
"Failed to build filesystem ignore rules.",
Some(source_path),
None,
Some(Rc::new(error)),
)];
}
};
let mut walker_builder = WalkBuilder::new(wiki_directory);
walker_builder
.current_dir(wiki_directory)
.follow_links(true)
.hidden(false)
.parents(false)
.require_git(false)
.overrides(overrides)
.filter_entry({
let wiki_path = wiki_path.to_owned();
let referenced_directories = referenced_directories.clone();
move |entry| {
entry.path() != wiki_path && !referenced_directories.contains(entry.path())
}
});
let mut errors = Vec::<Error>::new();
for result in walker_builder.build() {
let entry = match result {
Ok(entry) => entry,
Err(error) => {
errors.push(Error::new(
"Failed to walk wiki directory.",
Some(source_path),
None,
Some(Rc::new(error)),
));
if errors.len() >= maximum_errors {
break;
}
continue;
}
};
let path = entry.path();
let Some(file_type) = entry.file_type() else {
continue;
};
if file_type.is_file() && !referenced_files.contains(path) {
errors.push(Error::new(
&format!(
"File {} is not referenced.",
relative_path(wiki_directory, path).code_path(),
),
Some(source_path),
None,
None,
));
if errors.len() >= maximum_errors {
break;
}
}
}
errors.sort_by_key(ToString::to_string);
errors
}
fn errors_to_result(errors: Vec<Error>) -> Result<(), Vec<Error>> {
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
#[cfg(test)]
mod tests {
use super::{MAX_FILESYSTEM_ERRORS, validate as validate_wiki};
use crate::{error::Error, parser::parse as parse_wiki, wiki::Wiki};
use std::{
fs,
path::{Path, PathBuf},
process,
sync::atomic::{AtomicUsize, Ordering},
};
static NEXT_DIRECTORY: AtomicUsize = AtomicUsize::new(0);
struct TestDirectory(PathBuf);
struct TestWiki {
wiki: Wiki,
source_contents: String,
}
fn parse(source_contents: &str) -> Result<TestWiki, Vec<Error>> {
parse_wiki(Path::new("test.mull"), source_contents).map(|wiki| TestWiki {
wiki,
source_contents: source_contents.to_owned(),
})
}
fn validate(wiki: &TestWiki, wiki_path: &Path) -> Result<(), Vec<Error>> {
validate_wiki(
&wiki.wiki,
wiki_path,
Path::new("test.mull"),
&wiki.source_contents,
)
}
fn contains_error(errors: &[Error], message: &str) -> bool {
errors
.iter()
.any(|error| error.to_string().contains(message))
}
impl TestDirectory {
fn new() -> Self {
let sequence = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("mull-validation-{}-{sequence}", process::id()));
fs::create_dir(&path).unwrap();
fs::write(path.join("wiki.mull"), "# Home\n").unwrap();
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
fn wiki_path(&self) -> PathBuf {
self.0.join("wiki.mull")
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
fs::remove_dir_all(&self.0).unwrap();
}
}
#[test]
fn referenced_entries() {
let directory = TestDirectory::new();
fs::write(directory.path().join(".gitignore"), "ignored.txt\n").unwrap();
fs::write(directory.path().join(".secret"), "secret").unwrap();
fs::write(directory.path().join("ignored.txt"), "ignored").unwrap();
fs::create_dir(directory.path().join("images")).unwrap();
fs::write(directory.path().join("images/photo.jpg"), "photo").unwrap();
let wiki = parse(concat!(
"# Home\n[",
"file:.gitignore] [",
"file:.secret] [",
"dir:images]",
))
.unwrap();
assert!(validate(&wiki, &directory.wiki_path()).is_ok());
}
#[test]
fn wiki_directory_link() {
let directory = TestDirectory::new();
fs::write(directory.path().join("unmanaged.txt"), "content").unwrap();
let wiki = parse(concat!("# Home\n[", "dir:.]")).unwrap();
assert!(validate(&wiki, &directory.wiki_path()).is_ok());
}
#[test]
fn missing_wiki_path() {
let directory = TestDirectory::new();
let wiki_path = directory.wiki_path();
fs::remove_file(&wiki_path).unwrap();
let wiki = parse("# Elsewhere").unwrap();
let errors = validate(&wiki, &wiki_path).unwrap_err();
assert_eq!(errors.len(), 2);
assert!(
errors[0]
.to_string()
.contains("Wiki does not contain a `Home` node."),
);
assert!(
errors[1]
.to_string()
.contains(&format!("Failed to resolve `{}`.", wiki_path.display())),
);
}
#[test]
fn unreferenced_entries() {
let directory = TestDirectory::new();
fs::create_dir(directory.path().join("images")).unwrap();
fs::write(directory.path().join("images/photo.jpg"), "photo").unwrap();
let wiki = parse("# Home").unwrap();
let errors = validate(&wiki, &directory.wiki_path()).unwrap_err();
let photo_path = Path::new("images").join("photo.jpg");
assert_eq!(errors.len(), 1);
assert!(errors[0].to_string().contains(&format!(
"File `{}` is not referenced.",
photo_path.display(),
)));
}
#[test]
fn filesystem_link_error_limit() {
let directory = TestDirectory::new();
let links = (0..=MAX_FILESYSTEM_ERRORS)
.map(|index| format!(concat!("[", "file:missing-{}.txt]"), index))
.collect::<Vec<_>>()
.join(" ");
let wiki = parse(&format!("# Home\n{links}")).unwrap();
let errors = validate(&wiki, &directory.wiki_path()).unwrap_err();
assert_eq!(errors.len(), MAX_FILESYSTEM_ERRORS);
assert!(errors.iter().all(|error| {
let message = error.to_string();
message.contains("`missing-") && message.contains(".txt` not found.")
}));
}
#[test]
fn unreferenced_file_error_limit() {
let directory = TestDirectory::new();
for index in 0..=MAX_FILESYSTEM_ERRORS {
fs::write(
directory.path().join(format!("unreferenced-{index}.txt")),
"",
)
.unwrap();
}
let wiki = parse(concat!("# Home\n[", "file:missing.txt]")).unwrap();
let errors = validate(&wiki, &directory.wiki_path()).unwrap_err();
assert_eq!(errors.len(), MAX_FILESYSTEM_ERRORS);
assert!(errors[0].to_string().contains("`missing.txt` not found."));
assert!(
errors[1..]
.iter()
.all(|error| error.to_string().contains("File `unreferenced-")),
);
}
#[test]
fn implicitly_referenced_directories() {
let directory = TestDirectory::new();
fs::create_dir(directory.path().join("notes")).unwrap();
fs::create_dir(directory.path().join("notes/archive")).unwrap();
fs::write(directory.path().join("notes/current.txt"), "current").unwrap();
fs::write(directory.path().join("notes/archive/old.txt"), "old").unwrap();
let wiki = parse(concat!(
"# Home\n[",
"file:notes/current.txt] [",
"file:notes/archive/old.txt]",
))
.unwrap();
assert!(validate(&wiki, &directory.wiki_path()).is_ok());
}
#[test]
fn empty_directories() {
let directory = TestDirectory::new();
fs::create_dir(directory.path().join("empty")).unwrap();
fs::create_dir(directory.path().join("empty/nested")).unwrap();
let wiki = parse("# Home").unwrap();
assert!(validate(&wiki, &directory.wiki_path()).is_ok());
}
#[cfg(unix)]
#[test]
fn symlink_aliases() {
use std::os::unix::fs::symlink;
let directory = TestDirectory::new();
fs::write(directory.path().join("target.txt"), "content").unwrap();
symlink("target.txt", directory.path().join("first.txt")).unwrap();
symlink("target.txt", directory.path().join("second.txt")).unwrap();
let wiki = parse(concat!(
"# Home\n[",
"file:target.txt] [",
"file:first.txt]",
))
.unwrap();
let errors = validate(&wiki, &directory.wiki_path()).unwrap_err();
assert_eq!(errors.len(), 1);
assert!(contains_error(
&errors,
"File `second.txt` is not referenced.",
));
}
#[cfg(unix)]
#[test]
fn directory_symlink() {
use std::os::unix::fs::symlink;
let directory = TestDirectory::new();
fs::create_dir(directory.path().join("target")).unwrap();
fs::write(directory.path().join("target/file.txt"), "content").unwrap();
symlink("target", directory.path().join("alias")).unwrap();
let wiki = parse(concat!(
"# Home\n[",
"dir:target] [",
"file:alias/file.txt]",
))
.unwrap();
assert!(validate(&wiki, &directory.wiki_path()).is_ok());
}
#[cfg(unix)]
#[test]
fn external_directory_symlink() {
use std::os::unix::fs::symlink;
let directory = TestDirectory::new();
let external_directory = TestDirectory::new();
symlink(external_directory.path(), directory.path().join("external")).unwrap();
let wiki = parse(concat!("# Home\n[", "file:external/wiki.mull]")).unwrap();
assert!(validate(&wiki, &directory.wiki_path()).is_ok());
}
#[cfg(unix)]
#[test]
fn wiki_symlink() {
use std::os::unix::fs::symlink;
let directory = TestDirectory::new();
let wiki_path = directory.wiki_path();
let target_path = directory.path().join("wiki.txt");
fs::rename(&wiki_path, &target_path).unwrap();
fs::write(&target_path, concat!("# Home\n[", "file:wiki.txt]")).unwrap();
symlink("wiki.txt", &wiki_path).unwrap();
let wiki = parse(concat!("# Home\n[", "file:wiki.txt]")).unwrap();
assert!(validate(&wiki, &wiki_path).is_ok());
}
#[cfg(unix)]
#[test]
fn broken_symlink() {
use std::os::unix::fs::symlink;
let directory = TestDirectory::new();
symlink("missing", directory.path().join("broken")).unwrap();
let wiki = parse("# Home").unwrap();
assert!(
validate(&wiki, &directory.wiki_path())
.unwrap_err()
.iter()
.any(|error| error.to_string().contains("Failed to walk wiki directory.")),
);
}
#[cfg(unix)]
#[test]
fn symlink_cycle() {
use std::os::unix::fs::symlink;
let directory = TestDirectory::new();
symlink(".", directory.path().join("cycle")).unwrap();
let wiki = parse("# Home").unwrap();
assert!(
validate(&wiki, &directory.wiki_path())
.unwrap_err()
.iter()
.any(|error| error.to_string().contains("Failed to walk wiki directory.")),
);
}
#[test]
fn wrong_target_type() {
let directory = TestDirectory::new();
fs::create_dir(directory.path().join("images")).unwrap();
fs::write(directory.path().join("images/photo.jpg"), "photo").unwrap();
let wiki = parse(concat!("# Home\n[", "file:images]")).unwrap();
let photo_path = Path::new("images").join("photo.jpg");
let errors = validate(&wiki, &directory.wiki_path()).unwrap_err();
assert_eq!(errors.len(), 2);
assert!(contains_error(&errors, "`images` is not a file."));
assert!(contains_error(
&errors,
&format!("File `{}` is not referenced.", photo_path.display()),
));
}
#[test]
fn missing_text_link() {
let directory = TestDirectory::new();
let wiki = parse("# Home\nSee [Zulu] and [Alpha].").unwrap();
let errors = validate(&wiki, &directory.wiki_path()).unwrap_err();
assert_eq!(errors.len(), 2);
assert!(contains_error(&errors, "Node `Zulu` not found."));
assert!(contains_error(&errors, "Node `Alpha` not found."));
}
#[test]
fn repeated_missing_text_link() {
let directory = TestDirectory::new();
let wiki = parse("# Home\nSee [Missing] and [Missing].").unwrap();
let errors = validate(&wiki, &directory.wiki_path()).unwrap_err();
assert_eq!(errors.len(), 2);
assert!(
errors
.iter()
.all(|error| error.to_string().contains("Node `Missing` not found.")),
);
assert_ne!(errors[0].to_string(), errors[1].to_string());
}
#[test]
fn multiple_validation_errors() {
let directory = TestDirectory::new();
fs::write(directory.path().join("unreferenced.txt"), "content").unwrap();
let wiki = parse(concat!(
"# Home\nSee [Missing] and [",
"file:missing.txt].\n",
"# Orphan",
))
.unwrap();
let errors = validate(&wiki, &directory.wiki_path()).unwrap_err();
assert_eq!(errors.len(), 4);
assert!(contains_error(&errors, "Node `Missing` not found."));
assert!(contains_error(
&errors,
"Node `Orphan` is not reachable from `Home`.",
));
assert!(contains_error(&errors, "`missing.txt` not found."));
assert!(contains_error(
&errors,
"File `unreferenced.txt` is not referenced.",
));
}
#[test]
fn empty_text_link() {
let directory = TestDirectory::new();
let wiki = parse("# Home\nSee [].").unwrap();
let errors = validate(&wiki, &directory.wiki_path()).unwrap_err();
assert_eq!(errors.len(), 1);
assert!(contains_error(&errors, "Link target is empty."));
}
#[test]
fn missing_home() {
let directory = TestDirectory::new();
let wiki = parse("# Elsewhere").unwrap();
let errors = validate(&wiki, &directory.wiki_path()).unwrap_err();
assert_eq!(errors.len(), 1);
assert!(contains_error(
&errors,
"Wiki does not contain a `Home` node.",
));
}
#[test]
fn unreachable_nodes() {
let directory = TestDirectory::new();
let wiki = parse("# Home\nSee [Middle].\n# Middle\n# Zulu\n# Alpha").unwrap();
let errors = validate(&wiki, &directory.wiki_path()).unwrap_err();
assert_eq!(errors.len(), 2);
assert!(contains_error(
&errors,
"Node `Alpha` is not reachable from `Home`.",
));
assert!(contains_error(
&errors,
"Node `Zulu` is not reachable from `Home`.",
));
}
}