Add a poll-based file watcher.

This commit is contained in:
Eric Huss 2024-02-24 13:35:39 -08:00
parent 504900d7bd
commit 4345c2dd0b
9 changed files with 638 additions and 199 deletions

View File

@ -41,6 +41,7 @@ notify = { version = "6.1.1", optional = true }
notify-debouncer-mini = { version = "0.4.1", optional = true } notify-debouncer-mini = { version = "0.4.1", optional = true }
ignore = { version = "0.4.20", optional = true } ignore = { version = "0.4.20", optional = true }
pathdiff = { version = "0.2.1", optional = true } pathdiff = { version = "0.2.1", optional = true }
walkdir = { version = "2.3.3", optional = true }
# Serve feature # Serve feature
futures-util = { version = "0.3.28", optional = true } futures-util = { version = "0.3.28", optional = true }
@ -61,7 +62,7 @@ walkdir = "2.3.3"
[features] [features]
default = ["watch", "serve", "search"] default = ["watch", "serve", "search"]
watch = ["dep:notify", "dep:notify-debouncer-mini", "dep:ignore", "dep:pathdiff"] watch = ["dep:notify", "dep:notify-debouncer-mini", "dep:ignore", "dep:pathdiff", "dep:walkdir"]
serve = ["dep:futures-util", "dep:tokio", "dep:warp"] serve = ["dep:futures-util", "dep:tokio", "dep:warp"]
search = ["dep:elasticlunr-rs", "dep:ammonia"] search = ["dep:elasticlunr-rs", "dep:ammonia"]

View File

@ -0,0 +1,7 @@
#### --watcher
There are different backends used to determine when a file has changed.
* `poll` (default) --- Checks for file modifications by scanning the filesystem every second.
* `native` --- Uses the native operating system facilities to receive notifications when files change.
This can have less constant overhead, but may not be as reliable as the `poll` based watcher.

View File

@ -44,6 +44,8 @@ book. Relative paths are interpreted relative to the book's root directory. If
not specified it will default to the value of the `build.build-dir` key in not specified it will default to the value of the `build.build-dir` key in
`book.toml`, or to `./book`. `book.toml`, or to `./book`.
{{#include arg-watcher.md}}
#### Specify exclude patterns #### Specify exclude patterns
The `serve` command will not automatically trigger a build for files listed in The `serve` command will not automatically trigger a build for files listed in

View File

@ -27,6 +27,7 @@ book. Relative paths are interpreted relative to the book's root directory. If
not specified it will default to the value of the `build.build-dir` key in not specified it will default to the value of the `build.build-dir` key in
`book.toml`, or to `./book`. `book.toml`, or to `./book`.
{{#include arg-watcher.md}}
#### Specify exclude patterns #### Specify exclude patterns

View File

@ -36,6 +36,19 @@ pub trait CommandExt: Sized {
fn arg_open(self) -> Self { fn arg_open(self) -> Self {
self._arg(arg!(-o --open "Opens the compiled book in a web browser")) self._arg(arg!(-o --open "Opens the compiled book in a web browser"))
} }
fn arg_watcher(self) -> Self {
#[cfg(feature = "watch")]
return self._arg(
Arg::new("watcher")
.long("watcher")
.value_parser(["poll", "native"])
.default_value("poll")
.help("The filesystem watching technique"),
);
#[cfg(not(feature = "watch"))]
return self;
}
} }
impl CommandExt for Command { impl CommandExt for Command {

View File

@ -6,7 +6,6 @@ use clap::builder::NonEmptyStringValueParser;
use futures_util::sink::SinkExt; use futures_util::sink::SinkExt;
use futures_util::StreamExt; use futures_util::StreamExt;
use mdbook::errors::*; use mdbook::errors::*;
use mdbook::utils;
use mdbook::utils::fs::get_404_output_file; use mdbook::utils::fs::get_404_output_file;
use mdbook::MDBook; use mdbook::MDBook;
use std::net::{SocketAddr, ToSocketAddrs}; use std::net::{SocketAddr, ToSocketAddrs};
@ -43,12 +42,13 @@ pub fn make_subcommand() -> Command {
.help("Port to use for HTTP connections"), .help("Port to use for HTTP connections"),
) )
.arg_open() .arg_open()
.arg_watcher()
} }
// Serve command implementation // Serve command implementation
pub fn execute(args: &ArgMatches) -> Result<()> { pub fn execute(args: &ArgMatches) -> Result<()> {
let book_dir = get_book_dir(args); let book_dir = get_book_dir(args);
let mut book = MDBook::load(book_dir)?; let mut book = MDBook::load(&book_dir)?;
let port = args.get_one::<String>("port").unwrap(); let port = args.get_one::<String>("port").unwrap();
let hostname = args.get_one::<String>("hostname").unwrap(); let hostname = args.get_one::<String>("hostname").unwrap();
@ -97,23 +97,12 @@ pub fn execute(args: &ArgMatches) -> Result<()> {
} }
#[cfg(feature = "watch")] #[cfg(feature = "watch")]
watch::trigger_on_change(&book, move |paths, book_dir| { {
info!("Files changed: {:?}", paths); let watcher = watch::WatcherKind::from_str(args.get_one::<String>("watcher").unwrap());
info!("Building book..."); watch::rebuild_on_change(watcher, &book_dir, &update_config, &move || {
// FIXME: This area is really ugly because we need to re-set livereload :(
let result = MDBook::load(book_dir).and_then(|mut b| {
update_config(&mut b);
b.build()
});
if let Err(e) = result {
error!("Unable to load the book");
utils::log_backtrace(&e);
} else {
let _ = tx.send(Message::text("reload")); let _ = tx.send(Message::text("reload"));
}
}); });
}
let _ = thread_handle.join(); let _ = thread_handle.join();

View File

@ -1,14 +1,11 @@
use super::command_prelude::*; use super::command_prelude::*;
use crate::{get_book_dir, open}; use crate::{get_book_dir, open};
use ignore::gitignore::Gitignore;
use mdbook::errors::Result; use mdbook::errors::Result;
use mdbook::utils;
use mdbook::MDBook; use mdbook::MDBook;
use pathdiff::diff_paths;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::mpsc::channel;
use std::thread::sleep; mod native;
use std::time::Duration; mod poller;
// Create clap subcommand arguments // Create clap subcommand arguments
pub fn make_subcommand() -> Command { pub fn make_subcommand() -> Command {
@ -17,12 +14,28 @@ pub fn make_subcommand() -> Command {
.arg_dest_dir() .arg_dest_dir()
.arg_root_dir() .arg_root_dir()
.arg_open() .arg_open()
.arg_watcher()
}
pub enum WatcherKind {
Poll,
Native,
}
impl WatcherKind {
pub fn from_str(s: &str) -> WatcherKind {
match s {
"poll" => WatcherKind::Poll,
"native" => WatcherKind::Native,
_ => panic!("unsupported watcher {s}"),
}
}
} }
// Watch command implementation // Watch command implementation
pub fn execute(args: &ArgMatches) -> Result<()> { pub fn execute(args: &ArgMatches) -> Result<()> {
let book_dir = get_book_dir(args); let book_dir = get_book_dir(args);
let mut book = MDBook::load(book_dir)?; let mut book = MDBook::load(&book_dir)?;
let update_config = |book: &mut MDBook| { let update_config = |book: &mut MDBook| {
if let Some(dest_dir) = args.get_one::<PathBuf>("dest-dir") { if let Some(dest_dir) = args.get_one::<PathBuf>("dest-dir") {
@ -41,42 +54,21 @@ pub fn execute(args: &ArgMatches) -> Result<()> {
open(path); open(path);
} }
trigger_on_change(&book, |paths, book_dir| { let watcher = WatcherKind::from_str(args.get_one::<String>("watcher").unwrap());
info!("Files changed: {:?}\nBuilding book...\n", paths); rebuild_on_change(watcher, &book_dir, &update_config, &|| {});
let result = MDBook::load(book_dir).and_then(|mut b| {
update_config(&mut b);
b.build()
});
if let Err(e) = result {
error!("Unable to build the book");
utils::log_backtrace(&e);
}
});
Ok(()) Ok(())
} }
fn remove_ignored_files(book_root: &Path, paths: &[PathBuf]) -> Vec<PathBuf> { pub fn rebuild_on_change(
if paths.is_empty() { kind: WatcherKind,
return vec![]; book_dir: &Path,
} update_config: &dyn Fn(&mut MDBook),
post_build: &dyn Fn(),
match find_gitignore(book_root) { ) {
Some(gitignore_path) => { match kind {
let (ignore, err) = Gitignore::new(&gitignore_path); WatcherKind::Poll => self::poller::rebuild_on_change(book_dir, update_config, post_build),
if let Some(err) = err { WatcherKind::Native => self::native::rebuild_on_change(book_dir, update_config, post_build),
warn!(
"error reading gitignore `{}`: {err}",
gitignore_path.display()
);
}
filter_ignored_files(ignore, paths)
}
None => {
// There is no .gitignore file.
paths.iter().map(|path| path.to_path_buf()).collect()
}
} }
} }
@ -86,144 +78,3 @@ fn find_gitignore(book_root: &Path) -> Option<PathBuf> {
.map(|p| p.join(".gitignore")) .map(|p| p.join(".gitignore"))
.find(|p| p.exists()) .find(|p| p.exists())
} }
// Note: The usage of `canonicalize` may encounter occasional failures on the Windows platform, presenting a potential risk.
// For more details, refer to [Pull Request #2229](https://github.com/rust-lang/mdBook/pull/2229#discussion_r1408665981).
fn filter_ignored_files(ignore: Gitignore, paths: &[PathBuf]) -> Vec<PathBuf> {
let ignore_root = ignore
.path()
.canonicalize()
.expect("ignore root canonicalize error");
paths
.iter()
.filter(|path| {
let relative_path =
diff_paths(&path, &ignore_root).expect("One of the paths should be an absolute");
!ignore
.matched_path_or_any_parents(&relative_path, relative_path.is_dir())
.is_ignore()
})
.map(|path| path.to_path_buf())
.collect()
}
/// Calls the closure when a book source file is changed, blocking indefinitely.
pub fn trigger_on_change<F>(book: &MDBook, closure: F)
where
F: Fn(Vec<PathBuf>, &Path),
{
use notify::RecursiveMode::*;
// Create a channel to receive the events.
let (tx, rx) = channel();
let mut debouncer = match notify_debouncer_mini::new_debouncer(Duration::from_secs(1), tx) {
Ok(d) => d,
Err(e) => {
error!("Error while trying to watch the files:\n\n\t{:?}", e);
std::process::exit(1)
}
};
let watcher = debouncer.watcher();
// Add the source directory to the watcher
if let Err(e) = watcher.watch(&book.source_dir(), Recursive) {
error!("Error while watching {:?}:\n {:?}", book.source_dir(), e);
std::process::exit(1);
};
let _ = watcher.watch(&book.theme_dir(), Recursive);
// Add the book.toml file to the watcher if it exists
let _ = watcher.watch(&book.root.join("book.toml"), NonRecursive);
for dir in &book.config.build.extra_watch_dirs {
let path = book.root.join(dir);
let canonical_path = path.canonicalize().unwrap_or_else(|e| {
error!("Error while watching extra directory {path:?}:\n {e}");
std::process::exit(1);
});
if let Err(e) = watcher.watch(&canonical_path, Recursive) {
error!(
"Error while watching extra directory {:?}:\n {:?}",
canonical_path, e
);
std::process::exit(1);
}
}
info!("Listening for changes...");
loop {
let first_event = rx.recv().unwrap();
sleep(Duration::from_millis(50));
let other_events = rx.try_iter();
let all_events = std::iter::once(first_event).chain(other_events);
let paths: Vec<_> = all_events
.filter_map(|event| match event {
Ok(events) => Some(events),
Err(error) => {
log::warn!("error while watching for changes: {error}");
None
}
})
.flatten()
.map(|event| event.path)
.collect();
// If we are watching files outside the current repository (via extra-watch-dirs), then they are definitionally
// ignored by gitignore. So we handle this case by including such files into the watched paths list.
let any_external_paths = paths.iter().filter(|p| !p.starts_with(&book.root)).cloned();
let mut paths = remove_ignored_files(&book.root, &paths[..]);
paths.extend(any_external_paths);
if !paths.is_empty() {
closure(paths, &book.root);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ignore::gitignore::GitignoreBuilder;
use std::env;
#[test]
fn test_filter_ignored_files() {
let current_dir = env::current_dir().unwrap();
let ignore = GitignoreBuilder::new(&current_dir)
.add_line(None, "*.html")
.unwrap()
.build()
.unwrap();
let should_remain = current_dir.join("record.text");
let should_filter = current_dir.join("index.html");
let remain = filter_ignored_files(ignore, &[should_remain.clone(), should_filter]);
assert_eq!(remain, vec![should_remain])
}
#[test]
fn filter_ignored_files_should_handle_parent_dir() {
let current_dir = env::current_dir().unwrap();
let ignore = GitignoreBuilder::new(&current_dir)
.add_line(None, "*.html")
.unwrap()
.build()
.unwrap();
let parent_dir = current_dir.join("..");
let should_remain = parent_dir.join("record.text");
let should_filter = parent_dir.join("index.html");
let remain = filter_ignored_files(ignore, &[should_remain.clone(), should_filter]);
assert_eq!(remain, vec![should_remain])
}
}

189
src/cmd/watch/native.rs Normal file
View File

@ -0,0 +1,189 @@
//! A filesystem watcher using native operating system facilities.
use ignore::gitignore::Gitignore;
use mdbook::MDBook;
use std::path::{Path, PathBuf};
use std::sync::mpsc::channel;
use std::thread::sleep;
use std::time::Duration;
pub fn rebuild_on_change(
book_dir: &Path,
update_config: &dyn Fn(&mut MDBook),
post_build: &dyn Fn(),
) {
use notify::RecursiveMode::*;
let mut book = MDBook::load(book_dir).unwrap_or_else(|e| {
error!("failed to load book: {e}");
std::process::exit(1);
});
// Create a channel to receive the events.
let (tx, rx) = channel();
let mut debouncer = match notify_debouncer_mini::new_debouncer(Duration::from_secs(1), tx) {
Ok(d) => d,
Err(e) => {
error!("Error while trying to watch the files:\n\n\t{:?}", e);
std::process::exit(1)
}
};
let watcher = debouncer.watcher();
// Add the source directory to the watcher
if let Err(e) = watcher.watch(&book.source_dir(), Recursive) {
error!("Error while watching {:?}:\n {:?}", book.source_dir(), e);
std::process::exit(1);
};
let _ = watcher.watch(&book.theme_dir(), Recursive);
// Add the book.toml file to the watcher if it exists
let _ = watcher.watch(&book.root.join("book.toml"), NonRecursive);
for dir in &book.config.build.extra_watch_dirs {
let path = book.root.join(dir);
let canonical_path = path.canonicalize().unwrap_or_else(|e| {
error!("Error while watching extra directory {path:?}:\n {e}");
std::process::exit(1);
});
if let Err(e) = watcher.watch(&canonical_path, Recursive) {
error!(
"Error while watching extra directory {:?}:\n {:?}",
canonical_path, e
);
std::process::exit(1);
}
}
info!("Listening for changes...");
loop {
let first_event = rx.recv().unwrap();
sleep(Duration::from_millis(50));
let other_events = rx.try_iter();
let all_events = std::iter::once(first_event).chain(other_events);
let paths: Vec<_> = all_events
.filter_map(|event| match event {
Ok(events) => Some(events),
Err(error) => {
log::warn!("error while watching for changes: {error}");
None
}
})
.flatten()
.map(|event| event.path)
.collect();
// If we are watching files outside the current repository (via extra-watch-dirs), then they are definitionally
// ignored by gitignore. So we handle this case by including such files into the watched paths list.
let any_external_paths = paths.iter().filter(|p| !p.starts_with(&book.root)).cloned();
let mut paths = remove_ignored_files(&book.root, &paths[..]);
paths.extend(any_external_paths);
if !paths.is_empty() {
info!("Files changed: {paths:?}");
match MDBook::load(book_dir) {
Ok(mut b) => {
update_config(&mut b);
if let Err(e) = b.build() {
error!("failed to build the book: {e:?}");
} else {
post_build();
}
book = b;
}
Err(e) => error!("failed to load book config: {e:?}"),
}
}
}
}
fn remove_ignored_files(book_root: &Path, paths: &[PathBuf]) -> Vec<PathBuf> {
if paths.is_empty() {
return vec![];
}
match super::find_gitignore(book_root) {
Some(gitignore_path) => {
let (ignore, err) = Gitignore::new(&gitignore_path);
if let Some(err) = err {
warn!(
"error reading gitignore `{}`: {err}",
gitignore_path.display()
);
}
filter_ignored_files(ignore, paths)
}
None => {
// There is no .gitignore file.
paths.iter().map(|path| path.to_path_buf()).collect()
}
}
}
// Note: The usage of `canonicalize` may encounter occasional failures on the Windows platform, presenting a potential risk.
// For more details, refer to [Pull Request #2229](https://github.com/rust-lang/mdBook/pull/2229#discussion_r1408665981).
fn filter_ignored_files(ignore: Gitignore, paths: &[PathBuf]) -> Vec<PathBuf> {
let ignore_root = ignore
.path()
.canonicalize()
.expect("ignore root canonicalize error");
paths
.iter()
.filter(|path| {
let relative_path = pathdiff::diff_paths(&path, &ignore_root)
.expect("One of the paths should be an absolute");
!ignore
.matched_path_or_any_parents(&relative_path, relative_path.is_dir())
.is_ignore()
})
.map(|path| path.to_path_buf())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use ignore::gitignore::GitignoreBuilder;
use std::env;
#[test]
fn test_filter_ignored_files() {
let current_dir = env::current_dir().unwrap();
let ignore = GitignoreBuilder::new(&current_dir)
.add_line(None, "*.html")
.unwrap()
.build()
.unwrap();
let should_remain = current_dir.join("record.text");
let should_filter = current_dir.join("index.html");
let remain = filter_ignored_files(ignore, &[should_remain.clone(), should_filter]);
assert_eq!(remain, vec![should_remain])
}
#[test]
fn filter_ignored_files_should_handle_parent_dir() {
let current_dir = env::current_dir().unwrap();
let ignore = GitignoreBuilder::new(&current_dir)
.add_line(None, "*.html")
.unwrap()
.build()
.unwrap();
let parent_dir = current_dir.join("..");
let should_remain = parent_dir.join("record.text");
let should_filter = parent_dir.join("index.html");
let remain = filter_ignored_files(ignore, &[should_remain.clone(), should_filter]);
assert_eq!(remain, vec![should_remain])
}
}

386
src/cmd/watch/poller.rs Normal file
View File

@ -0,0 +1,386 @@
//! A simple poll-based filesystem watcher.
//!
//! This exists because the native change notifications have historically had
//! lots of problems. Various operating systems and different filesystems have
//! had problems correctly reporting changes.
use ignore::gitignore::Gitignore;
use mdbook::MDBook;
use pathdiff::diff_paths;
use std::collections::HashMap;
use std::fs::FileType;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime};
use walkdir::WalkDir;
/// Calls the closure when a book source file is changed, blocking indefinitely.
pub fn rebuild_on_change(
book_dir: &Path,
update_config: &dyn Fn(&mut MDBook),
post_build: &dyn Fn(),
) {
let mut book = MDBook::load(book_dir).unwrap_or_else(|e| {
error!("failed to load book: {e}");
std::process::exit(1);
});
let mut watcher = Watcher::new(book_dir);
info!("Watching for changes...");
// Scan once to initialize the starting point.
watcher.set_roots(&book);
watcher.scan();
// Track average scan time, to help investigate if the poller is taking
// undesirably long. This is not a rigorous benchmark, just a rough
// estimate.
const AVG_SIZE: usize = 60;
let mut avgs = vec![0.0; AVG_SIZE];
let mut avg_i = 0;
loop {
std::thread::sleep(Duration::new(1, 0));
watcher.set_roots(&book);
let start = Instant::now();
let paths = watcher.scan();
let elapsed = start.elapsed().as_secs_f64();
avgs[avg_i] = elapsed;
avg_i += 1;
if avg_i >= AVG_SIZE {
avg_i = 0;
let avg = avgs.iter().sum::<f64>() / (avgs.len() as f64);
trace!(
"scan average time: {avg:.2}s, scan size is {}",
watcher.path_data.len()
);
}
if !paths.is_empty() {
info!("Files changed: {paths:?}");
match MDBook::load(book_dir) {
Ok(mut b) => {
update_config(&mut b);
if let Err(e) = b.build() {
error!("failed to build the book: {e:?}");
} else {
post_build();
}
book = b;
}
Err(e) => error!("failed to load book config: {e:?}"),
}
}
}
}
#[derive(PartialEq)]
struct PathData {
file_type: FileType,
mtime: SystemTime,
size: u64,
}
/// A very simple poll-watcher that scans for modified files.
#[derive(Default)]
struct Watcher {
/// The root paths where it will recursively scan for changes.
root_paths: Vec<PathBuf>,
/// Data about files on disk.
path_data: HashMap<PathBuf, PathData>,
/// Filters paths that will be watched.
ignore: Option<(PathBuf, Gitignore)>,
}
impl Watcher {
fn new(book_root: &Path) -> Watcher {
// FIXME: ignore should be reloaded when it changes.
let ignore = super::find_gitignore(book_root).map(|gitignore_path| {
let (ignore, err) = Gitignore::new(&gitignore_path);
if let Some(err) = err {
warn!(
"error reading gitignore `{}`: {err}",
gitignore_path.display()
);
}
// Note: The usage of `canonicalize` may encounter occasional
// failures on the Windows platform, presenting a potential risk.
// For more details, refer to [Pull Request
// #2229](https://github.com/rust-lang/mdBook/pull/2229#discussion_r1408665981).
let ignore_path = ignore
.path()
.canonicalize()
.expect("ignore root canonicalize error");
(ignore_path, ignore)
});
Watcher {
ignore,
..Default::default()
}
}
/// Sets the root directories where scanning will start.
fn set_roots(&mut self, book: &MDBook) {
let mut root_paths = vec![
book.source_dir(),
book.theme_dir(),
book.root.join("book.toml"),
];
root_paths.extend(
book.config
.build
.extra_watch_dirs
.iter()
.map(|path| book.root.join(path)),
);
if let Some(html_config) = book.config.html_config() {
root_paths.extend(
html_config
.additional_css
.iter()
.chain(html_config.additional_js.iter())
.map(|path| book.root.join(path)),
);
}
self.root_paths = root_paths;
}
/// Scans for changes.
///
/// Returns the paths that have changed.
fn scan(&mut self) -> Vec<PathBuf> {
let ignore = &self.ignore;
let new_path_data: HashMap<_, _> = self
.root_paths
.iter()
.filter(|root| root.exists())
.flat_map(|root| {
WalkDir::new(root)
.follow_links(true)
.into_iter()
.filter_entry(|entry| {
if let Some((ignore_path, ignore)) = ignore {
let path = entry.path();
// Canonicalization helps with removing `..` and
// `.` entries, which can cause issues with
// diff_paths.
let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let relative_path = diff_paths(&path, &ignore_path)
.expect("One of the paths should be an absolute");
if ignore
.matched_path_or_any_parents(&relative_path, relative_path.is_dir())
.is_ignore()
{
trace!("ignoring {path:?}");
return false;
}
}
true
})
.filter_map(move |entry| {
let entry = match entry {
Ok(e) => e,
Err(e) => {
debug!("failed to scan {root:?}: {e}");
return None;
}
};
if entry.file_type().is_dir() {
// Changes to directories themselves aren't
// particularly interesting.
return None;
}
let path = entry.path().to_path_buf();
let meta = match entry.metadata() {
Ok(meta) => meta,
Err(e) => {
debug!("failed to scan {path:?}: {e}");
return None;
}
};
let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
let pd = PathData {
file_type: meta.file_type(),
mtime,
size: meta.len(),
};
Some((path, pd))
})
})
.collect();
let mut paths = Vec::new();
for (new_path, new_data) in &new_path_data {
match self.path_data.get(new_path) {
Some(old_data) => {
if new_data != old_data {
paths.push(new_path.to_path_buf());
}
}
None => {
paths.push(new_path.clone());
}
}
}
for old_path in self.path_data.keys() {
if !new_path_data.contains_key(old_path) {
paths.push(old_path.to_path_buf());
}
}
self.path_data = new_path_data;
paths
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Helper for testing the watcher.
fn check_watch_behavior(
gitignore_path: &str,
gitignore: &str,
book_root_path: &str,
ignored: &[&str],
not_ignored: &[&str],
extra_setup: &dyn Fn(&Path),
) {
// Create the book and initialize things.
let temp = tempfile::Builder::new()
.prefix("mdbook-")
.tempdir()
.unwrap();
let root = temp.path();
let book_root = root.join(book_root_path);
// eprintln!("book_root={book_root:?}",);
MDBook::init(&book_root).build().unwrap();
std::fs::write(root.join(gitignore_path), gitignore).unwrap();
let create = |paths: &[&str]| {
let mut paths = paths
.iter()
.map(|path| root.join(path))
.inspect(|path| {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "initial content").unwrap();
})
.map(|path| path.canonicalize().unwrap())
.collect::<Vec<_>>();
paths.sort();
paths
};
let ignored = create(ignored);
let not_ignored = create(not_ignored);
extra_setup(&book_root);
// Create a watcher and check its behavior.
let book = MDBook::load(&book_root).unwrap();
let mut watcher = Watcher::new(&book_root);
watcher.set_roots(&book);
// Do an initial scan to initialize its state.
watcher.scan();
// Verify the steady state is empty.
let changed = watcher.scan();
assert_eq!(changed, Vec::<PathBuf>::new());
// Modify all files, and verify that only not_ignored are detected.
for path in ignored.iter().chain(not_ignored.iter()) {
std::fs::write(path, "modified").unwrap();
}
let changed = watcher.scan();
let mut changed = changed
.into_iter()
.map(|p| p.canonicalize().unwrap())
.collect::<Vec<_>>();
changed.sort();
assert_eq!(changed, not_ignored);
// Verify again that steady state is empty.
let changed = watcher.scan();
assert_eq!(changed, Vec::<PathBuf>::new());
}
#[test]
fn test_ignore() {
// Basic gitignore test.
check_watch_behavior(
"foo/.gitignore",
"*.tmp",
"foo",
&["foo/src/somefile.tmp"],
&["foo/src/chapter.md"],
&|_book_root| {},
);
}
#[test]
fn test_ignore_in_parent() {
// gitignore is in the parent of the book
check_watch_behavior(
".gitignore",
"*.tmp\nsomedir/\n/inroot\n/foo/src/inbook\n",
"foo",
&[
"foo/src/somefile.tmp",
"foo/src/somedir/somefile",
"inroot/somefile",
"foo/src/inbook/somefile",
],
&["foo/src/inroot/somefile"],
&|_book_root| {},
);
}
#[test]
fn test_ignore_canonical() {
// test with path with ..
check_watch_behavior(
".gitignore",
"*.tmp\nsomedir/\n/foo/src/inbook\n",
"bar/../foo",
&[
"foo/src/somefile.tmp",
"foo/src/somedir/somefile",
"foo/src/inbook/somefile",
],
&["foo/src/chapter.md"],
&|_book_root| {},
);
}
#[test]
fn test_scan_extra_watch() {
// Check behavior with extra-watch-dirs
check_watch_behavior(
".gitignore",
"*.tmp\n/outside-root/ignoreme\n/foo/examples/ignoreme\n",
"foo",
&[
"foo/src/somefile.tmp",
"foo/examples/example.tmp",
"outside-root/somefile.tmp",
"outside-root/ignoreme",
"foo/examples/ignoreme",
],
&[
"foo/src/chapter.md",
"foo/examples/example.rs",
"foo/examples/example2.rs",
"outside-root/image.png",
],
&|book_root| {
std::fs::write(
book_root.join("book.toml"),
r#"
[book]
title = "foo"
[build]
extra-watch-dirs = [
"examples",
"../outside-root",
]
"#,
)
.unwrap();
},
);
}
}