use anyhow::{Context, Result};
use log::{debug, trace, warn};
use notify::event::{AccessKind, EventKind, ModifyKind};
use notify::{RecommendedWatcher, RecursiveMode};
use notify_debouncer_full::{DebouncedEvent, RecommendedCache, new_debouncer};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver};
use std::time::{Duration, Instant};
fn is_mutation_event(kind: &EventKind) -> bool {
match kind {
EventKind::Create(_) | EventKind::Remove(_) => true,
EventKind::Modify(ModifyKind::Data(_))
| EventKind::Modify(ModifyKind::Name(_))
| EventKind::Modify(ModifyKind::Any) => true,
EventKind::Modify(ModifyKind::Metadata(_)) | EventKind::Modify(ModifyKind::Other) => false,
EventKind::Access(AccessKind::Any)
| EventKind::Access(AccessKind::Read)
| EventKind::Access(AccessKind::Open(_))
| EventKind::Access(AccessKind::Close(_))
| EventKind::Access(AccessKind::Other) => false,
EventKind::Any | EventKind::Other => false,
}
}
use crate::config::Config;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ChangeType {
Config,
Content(PathBuf),
Template(PathBuf),
Css,
Asset(PathBuf),
}
#[derive(Debug, Default)]
pub struct ChangeSet {
pub full_rebuild: bool,
pub rebuild_css: bool,
pub rebuild_home: bool,
pub content_files: HashSet<PathBuf>,
pub asset_files: HashSet<PathBuf>,
pub template_files: HashSet<PathBuf>,
}
impl ChangeSet {
pub fn is_empty(&self) -> bool {
!self.full_rebuild
&& !self.rebuild_css
&& !self.rebuild_home
&& self.content_files.is_empty()
&& self.asset_files.is_empty()
&& self.template_files.is_empty()
}
pub fn has_template_changes(&self) -> bool {
!self.template_files.is_empty()
}
pub fn has_asset_changes(&self) -> bool {
!self.asset_files.is_empty()
}
fn add(&mut self, change: ChangeType) {
match change {
ChangeType::Config => self.full_rebuild = true,
ChangeType::Content(path) => {
self.content_files.insert(path);
}
ChangeType::Template(path) => {
let canonical = path.canonicalize().unwrap_or(path);
self.template_files.insert(canonical);
}
ChangeType::Css => self.rebuild_css = true,
ChangeType::Asset(path) => {
self.asset_files.insert(path);
}
}
}
fn optimize(&mut self) {
if self.full_rebuild {
self.rebuild_css = false;
self.rebuild_home = false;
self.content_files.clear();
self.asset_files.clear();
self.template_files.clear();
}
}
}
pub struct FileWatcher {
project_dir: PathBuf,
output_dir: PathBuf,
config_path: PathBuf,
templates_dir: PathBuf,
rx: Receiver<Result<Vec<DebouncedEvent>, Vec<notify::Error>>>,
_watcher: notify_debouncer_full::Debouncer<RecommendedWatcher, RecommendedCache>,
}
impl FileWatcher {
pub fn new(project_dir: &Path, config: &Config, output_dir: &Path) -> Result<Self> {
let project_dir = project_dir
.canonicalize()
.unwrap_or_else(|_| project_dir.to_path_buf());
let output_dir = output_dir
.canonicalize()
.unwrap_or_else(|_| output_dir.to_path_buf());
let config_path = project_dir.join("config.lua");
let templates_dir = project_dir.join(&config.paths.templates);
let templates_dir = templates_dir.canonicalize().unwrap_or(templates_dir);
let config_path = config_path.canonicalize().unwrap_or(config_path);
let (tx, rx) = mpsc::channel();
let mut debouncer = new_debouncer(Duration::from_millis(300), None, tx)
.context("Failed to create file watcher")?;
if config_path.exists() {
trace!("Watching config: {:?}", config_path);
debouncer
.watch(&config_path, RecursiveMode::NonRecursive)
.with_context(|| format!("Failed to watch config: {:?}", config_path))?;
}
trace!("Watching project: {:?}", project_dir);
debouncer
.watch(&project_dir, RecursiveMode::Recursive)
.with_context(|| format!("Failed to watch project: {:?}", project_dir))?;
debug!("File watcher initialized");
rs_print!("Watching for changes...");
rs_print!(" Project: {:?}", project_dir);
rs_print!(" Templates: {:?}", templates_dir);
Ok(Self {
project_dir,
output_dir,
config_path,
templates_dir,
rx,
_watcher: debouncer,
})
}
pub fn wait_for_changes(&self) -> Result<ChangeSet> {
let mut changes = ChangeSet::default();
trace!("Waiting for file changes...");
let process_events = |events: Vec<DebouncedEvent>, changes: &mut ChangeSet| {
for event in events {
if !is_mutation_event(&event.event.kind) {
trace!(
"Ignoring non-mutation event: {:?} {:?}",
event.event.kind, event.event.paths
);
continue;
}
for path in &event.event.paths {
if let Some(change) = self.classify_change(path) {
trace!("Classified change: {:?} -> {:?}", path, change);
changes.add(change);
}
}
}
};
match self.rx.recv() {
Ok(Ok(events)) => {
trace!("Received {} file events", events.len());
process_events(events, &mut changes);
}
Ok(Err(errs)) => {
for e in errs {
warn!("Watch error: {:?}", e);
}
}
Err(e) => {
return Err(anyhow::anyhow!("Watch channel closed: {:?}", e));
}
}
let drain_start = Instant::now();
while drain_start.elapsed() < Duration::from_millis(50) {
match self.rx.try_recv() {
Ok(Ok(events)) => process_events(events, &mut changes),
Ok(Err(errs)) => {
for e in errs {
warn!("Watch error: {:?}", e);
}
}
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => break,
}
}
changes.optimize();
Ok(changes)
}
fn classify_change(&self, path: &Path) -> Option<ChangeType> {
let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let path = path.as_path();
if path.starts_with(&self.output_dir) {
trace!("Skipping output directory path: {:?}", path);
return None;
}
if path
.components()
.any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
{
trace!("Skipping hidden path: {:?}", path);
return None;
}
if path == self.config_path {
return Some(ChangeType::Config);
}
if path.starts_with(&self.templates_dir) {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext == "html" || ext == "htm" {
return Some(ChangeType::Template(path.to_path_buf()));
}
return None;
}
if path.starts_with(&self.project_dir) {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext == "css" {
return Some(ChangeType::Css);
}
if matches!(
ext,
"js" | "ts"
| "mjs"
| "mts"
| "png"
| "jpg"
| "jpeg"
| "gif"
| "webp"
| "svg"
| "ico"
| "woff"
| "woff2"
| "ttf"
| "otf"
| "eot"
) && let Ok(rel) = path.strip_prefix(&self.project_dir)
{
return Some(ChangeType::Asset(rel.to_path_buf()));
}
if matches!(ext, "md" | "html" | "htm" | "json" | "lua")
&& let Ok(rel) = path.strip_prefix(&self.project_dir)
{
return Some(ChangeType::Content(rel.to_path_buf()));
}
}
None
}
pub fn project_dir(&self) -> &Path {
&self.project_dir
}
}
pub fn format_changes(changes: &ChangeSet) -> String {
let mut parts = Vec::new();
if changes.full_rebuild {
return "config changed (full rebuild)".to_string();
}
if !changes.template_files.is_empty() {
parts.push(format!("{} templates", changes.template_files.len()));
}
if changes.rebuild_css {
parts.push("styles".to_string());
}
if !changes.asset_files.is_empty() {
parts.push(format!("{} assets", changes.asset_files.len()));
}
if changes.rebuild_home {
parts.push("home".to_string());
}
if !changes.content_files.is_empty() {
parts.push(format!("{} content files", changes.content_files.len()));
}
if parts.is_empty() {
return "no actionable changes".to_string();
}
parts.join(", ")
}