use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::document::{Document, Entry, KindName};
use crate::index::Index;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct WatchSet {
pub deep: BTreeSet<PathBuf>,
pub shallow: BTreeSet<PathBuf>,
}
impl WatchSet {
pub fn plan(served: &Path, index: &Index, entry: &Entry) -> Self {
let sight = Sight {
served,
index,
roots: watch_roots(served, index, entry),
};
let mut set = Self::default();
if sight.walk(served, &mut set) {
set.deep.insert(served.to_path_buf());
}
set
}
}
struct Sight<'a> {
served: &'a Path,
index: &'a Index,
roots: Vec<PathBuf>,
}
impl Sight<'_> {
fn walk(&self, folder: &Path, set: &mut WatchSet) -> bool {
let Ok(entries) = std::fs::read_dir(folder) else {
return true;
};
let mut clean = folder != self.served.join(".devpage");
let mut whole = Vec::new();
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
continue;
}
let child = entry.path();
if !self.in_sight(&child) {
clean = false;
} else if self.walk(&child, set) {
whole.push(child);
} else {
clean = false;
}
}
if !clean {
set.shallow.insert(folder.to_path_buf());
set.deep.extend(whole);
}
clean
}
fn in_sight(&self, folder: &Path) -> bool {
let Ok(relative) = folder.strip_prefix(self.served) else {
return false;
};
if relative == Path::new(".devpage") || relative.starts_with(".devpage/comments") {
return true;
}
if relative.starts_with(".devpage")
|| relative.components().any(|part| part.as_os_str() == ".git")
{
return false;
}
!self.index.ignored(folder, true)
|| self.roots.iter().any(|root| {
root.starts_with(folder)
|| (folder.starts_with(root) && self.index.ignored(root, true))
})
}
}
fn watch_roots(served: &Path, index: &Index, entry: &Entry) -> Vec<PathBuf> {
let document = entry.document();
let mut roots = document.watch_roots().to_vec();
for file in index.documents() {
if file != entry.file()
&& index.kind(&file) == Some(KindName::Site)
&& let Ok(site) = Document::load(&file, served)
{
roots.extend_from_slice(site.watch_roots());
}
}
roots
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
fn served(name: &str, site: &str, files: &[&str]) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("devpage-watch-set-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("docs")).unwrap();
std::fs::write(dir.join("docs/README.md"), "# Home").unwrap();
std::fs::write(dir.join("docs/devpage.yml"), site).unwrap();
for file in files {
let path = dir.join(file);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
if file.ends_with('/') {
std::fs::create_dir_all(&path).unwrap();
} else {
std::fs::write(&path, "").unwrap();
}
}
dir.canonicalize().unwrap()
}
fn plan(dir: &Path) -> WatchSet {
let document = Arc::new(Document::load(&dir.join("docs"), dir).unwrap());
WatchSet::plan(dir, &Index::build(dir), &Entry::new(document))
}
fn set(dir: &Path, deep: &[&str], shallow: &[&str]) -> WatchSet {
let join = |folders: &[&str]| folders.iter().map(|f| dir.join(f)).collect();
WatchSet {
deep: join(deep),
shallow: join(shallow),
}
}
const SITE: &str = "kind: site\ntitle: T\nroot: README.md\n";
#[test]
fn a_folder_with_nothing_out_of_sight_is_watched_whole() {
let dir = served("whole", SITE, &["src/main.rs", "docs/guides/a.md"]);
assert_eq!(plan(&dir), set(&dir, &[""], &[]));
}
#[test]
fn ignored_folders_and_git_leave_their_parents_shallow() {
let dir = served(
"repo",
SITE,
&[
".gitignore",
".git/HEAD",
"target/debug/devpage",
"src/main.rs",
"crate/.gitignore",
"crate/src/lib.rs",
"crate/target/debug/x",
"crate/benches/b.rs",
],
);
std::fs::write(dir.join(".gitignore"), "/target/\n").unwrap();
std::fs::write(dir.join("crate/.gitignore"), "target/\n").unwrap();
assert_eq!(
plan(&dir),
set(
&dir,
&["docs", "src", "crate/src", "crate/benches"],
&["", "crate"]
)
);
}
#[test]
fn the_comment_store_is_watched_and_nothing_else_in_devpage() {
let dir = served(
"devpage",
SITE,
&[".devpage/comments/docs/README.md/x.yml", ".devpage/cache/y"],
);
assert_eq!(
plan(&dir),
set(&dir, &["docs", ".devpage/comments"], &["", ".devpage"])
);
}
#[test]
fn an_ignored_folder_a_watch_pattern_reaches_into_is_watched() {
let dir = served(
"named",
"kind: site\ntitle: T\nroot: README.md\nwatch: [\"build/*.svg\"]\n",
&[
"docs/.gitignore",
"docs/build/chart.svg",
"docs/build/deep/x",
"docs/out/x",
".gitignore",
"target/x",
],
);
std::fs::write(dir.join("docs/.gitignore"), "build/\nout/\n").unwrap();
std::fs::write(dir.join(".gitignore"), "/target/\n").unwrap();
assert_eq!(plan(&dir), set(&dir, &["docs/build"], &["", "docs"]));
}
#[test]
fn a_pattern_that_only_matches_into_an_ignored_folder_does_not_watch_it() {
let dir = served(
"glob",
"kind: site\ntitle: T\nroot: README.md\nwatch: [\"**/*.md\"]\n",
&[
"docs/.gitignore",
"docs/build/out.md",
"docs/guides/a.md",
".gitignore",
"target/x",
],
);
std::fs::write(dir.join("docs/.gitignore"), "build/\n").unwrap();
std::fs::write(dir.join(".gitignore"), "/target/\n").unwrap();
assert_eq!(plan(&dir), set(&dir, &["docs/guides"], &["", "docs"]));
}
#[test]
fn a_pattern_reaching_into_an_ignored_folder_watches_the_folders_under_it() {
let dir = served(
"under",
"kind: site\ntitle: T\nroot: README.md\nwatch: [\"build/**/*.svg\"]\n",
&["docs/.gitignore", "docs/build/deep/chart.svg"],
);
std::fs::write(dir.join("docs/.gitignore"), "build/\n").unwrap();
assert_eq!(plan(&dir), set(&dir, &[""], &[]));
}
}