rs-web 0.4.2

A fast, Lua-driven static site generator with encryption support
Documentation
//! File watcher for incremental rebuilds

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,
        // Metadata-only / Other / Access(*) are intentionally ignored.
        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;

/// Types of changes that trigger different rebuild strategies
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ChangeType {
    /// Config file changed - requires full rebuild
    Config,
    /// Content file changed - requires data()/pages() rebuild
    Content(PathBuf),
    /// Template file changed - re-render affected pages
    Template(PathBuf),
    /// CSS/style file changed - triggers before_build hook (CSS hot reload)
    Css,
    /// Static asset changed (JS/images/fonts) - triggers before_build hook
    Asset(PathBuf),
}

/// Aggregated changes from a batch of file events
#[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()
    }

    /// Check if any templates changed
    pub fn has_template_changes(&self) -> bool {
        !self.template_files.is_empty()
    }

    /// Check if any assets changed
    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) => {
                // Path is already relative (project prefix stripped in classify_change)
                self.content_files.insert(path);
            }
            ChangeType::Template(path) => {
                // Template paths are absolute (from templates_dir)
                let canonical = path.canonicalize().unwrap_or(path);
                self.template_files.insert(canonical);
            }
            ChangeType::Css => self.rebuild_css = true,
            ChangeType::Asset(path) => {
                // Path is already relative (project prefix stripped in classify_change)
                self.asset_files.insert(path);
            }
        }
    }

    fn optimize(&mut self) {
        // If full rebuild, clear incremental changes
        if self.full_rebuild {
            self.rebuild_css = false;
            self.rebuild_home = false;
            self.content_files.clear();
            self.asset_files.clear();
            self.template_files.clear();
        }
    }
}

/// File watcher for incremental builds
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);

        // Create channel for events
        let (tx, rx) = mpsc::channel();

        let mut debouncer = new_debouncer(Duration::from_millis(300), None, tx)
            .context("Failed to create file watcher")?;

        // Watch config file
        if config_path.exists() {
            trace!("Watching config: {:?}", config_path);
            debouncer
                .watch(&config_path, RecursiveMode::NonRecursive)
                .with_context(|| format!("Failed to watch config: {:?}", config_path))?;
        }

        // Watch project directory for content changes (Lua decides what's content)
        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,
        })
    }

    /// Wait for changes and return aggregated change set
    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);
                    }
                }
            }
        };

        // Block until we receive events
        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));
            }
        }

        // Drain any additional pending events (with short timeout)
        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)
    }

    /// Classify a file path into a change type
    fn classify_change(&self, path: &Path) -> Option<ChangeType> {
        let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
        let path = path.as_path();

        // Skip events from the output directory (prevents feedback loop)
        if path.starts_with(&self.output_dir) {
            trace!("Skipping output directory path: {:?}", path);
            return None;
        }

        // Skip hidden files and directories
        if path
            .components()
            .any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
        {
            trace!("Skipping hidden path: {:?}", path);
            return None;
        }

        // Config file
        if path == self.config_path {
            return Some(ChangeType::Config);
        }

        // Templates directory
        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;
        }

        // Any file in project directory
        if path.starts_with(&self.project_dir) {
            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");

            // CSS files trigger before_build hook (CSS hot reload)
            if ext == "css" {
                return Some(ChangeType::Css);
            }

            // Static assets trigger before_build hook
            // Includes: JS/TS, images, fonts, etc.
            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()));
            }

            // Content files trigger rebuild (markdown, data files, Lua scripts)
            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
    }

    /// Get the project directory
    pub fn project_dir(&self) -> &Path {
        &self.project_dir
    }
}

/// Format a change set for display
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(", ")
}