use colored::Colorize;
use notify::event::{EventKind, ModifyKind};
use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::{Duration, Instant, SystemTime};
use crate::console::icon_fail;
const IGNORED_SUBSTRINGS: &[&str] = &[
"/__pycache__/",
"/.git/",
"/.venv/",
"/venv/",
"/node_modules/",
"/vendor/",
"/dist/",
"/target/",
"/logs/",
"/.tina4/",
];
const IGNORED_EXTENSIONS: &[&str] = &[
"log", "db", "db-wal", "db-shm", "sqlite", "sqlite-journal",
"tmp", "swp", "swo", "pyc", "pyo",
"scss", ];
fn is_meaningful_event(event: &Event) -> bool {
let kind_ok = matches!(
event.kind,
EventKind::Create(_)
| EventKind::Remove(_)
| EventKind::Modify(ModifyKind::Data(_))
| EventKind::Modify(ModifyKind::Name(_))
| EventKind::Modify(ModifyKind::Any)
| EventKind::Any
);
if !kind_ok {
return false;
}
for path in &event.paths {
let s = path.to_string_lossy();
if IGNORED_SUBSTRINGS.iter().any(|sub| s.contains(sub)) {
return false;
}
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if IGNORED_EXTENSIONS.contains(&ext.to_lowercase().as_str()) {
return false;
}
}
}
true
}
fn file_mtime(path: &Path) -> Option<SystemTime> {
std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
}
pub fn watch_and_reload(port: u16) {
let (tx, rx) = mpsc::channel();
let config = Config::default().with_poll_interval(Duration::from_secs(2));
let mut watcher: RecommendedWatcher =
Watcher::new(tx, config).expect("Failed to create watcher");
let dirs = ["src", "migrations"];
for dir in &dirs {
let p = Path::new(dir);
if p.exists() {
let _ = watcher.watch(p, RecursiveMode::Recursive);
}
}
let env_path = Path::new(".env");
if env_path.exists() {
let _ = watcher.watch(env_path, RecursiveMode::NonRecursive);
}
let mut mtimes: HashMap<PathBuf, SystemTime> = HashMap::new();
let mut last_reload = Instant::now();
let url = format!("http://127.0.0.1:{}/__dev/api/reload", port);
loop {
match rx.recv() {
Ok(Ok(event)) => {
if !is_meaningful_event(&event) {
continue;
}
let mut any_changed = false;
let mut changed_path: Option<PathBuf> = None;
for p in &event.paths {
if let Some(mt) = file_mtime(p) {
match mtimes.get(p) {
Some(prev) if *prev == mt => continue,
_ => {
mtimes.insert(p.clone(), mt);
any_changed = true;
if changed_path.is_none() {
changed_path = Some(p.clone());
}
}
}
} else {
any_changed = true;
if changed_path.is_none() {
changed_path = Some(p.clone());
}
}
}
if !any_changed {
continue;
}
if last_reload.elapsed() < Duration::from_millis(500) {
continue;
}
last_reload = Instant::now();
let file = changed_path
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let reload_type = if file.ends_with(".css") || file.ends_with(".scss") {
"css"
} else {
"reload"
};
let body = format!(
r#"{{"type":"{}","file":"{}"}}"#,
reload_type,
file.replace('\\', "/")
);
let url_clone = url.clone();
std::thread::spawn(move || {
let _ = ureq_post(&url_clone, &body);
});
}
Ok(Err(e)) => {
eprintln!("{} Watcher event error: {}", icon_fail().red(), e);
}
Err(e) => {
eprintln!("{} Watcher channel closed: {}", icon_fail().red(), e);
break;
}
}
}
}
fn ureq_post(url: &str, body: &str) -> Result<(), String> {
use std::io::{Read, Write};
use std::net::TcpStream;
let url = url.strip_prefix("http://").unwrap_or(url);
let (host_port, path) = url.split_once('/').unwrap_or((url, ""));
let path = format!("/{}", path);
let mut stream = TcpStream::connect(host_port).map_err(|e| e.to_string())?;
stream
.set_write_timeout(Some(Duration::from_secs(2)))
.ok();
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.ok();
let request = format!(
"POST {} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
path, host_port, body.len(), body
);
stream.write_all(request.as_bytes()).map_err(|e| e.to_string())?;
let mut response = String::new();
let _ = stream.read_to_string(&mut response);
Ok(())
}
pub fn watch_scss(input_dir: &str, output_dir: &str, minify: bool) {
let (tx, rx) = mpsc::channel();
let config = Config::default().with_poll_interval(Duration::from_secs(2));
let mut watcher: RecommendedWatcher =
Watcher::new(tx, config).expect("Failed to create watcher");
let input = Path::new(input_dir);
if input.exists() {
watcher
.watch(input, RecursiveMode::Recursive)
.expect("Failed to watch SCSS directory");
}
let mut last_compile = Instant::now();
let mut mtimes: HashMap<PathBuf, SystemTime> = HashMap::new();
loop {
match rx.recv() {
Ok(Ok(event)) => {
let kind_ok = matches!(
event.kind,
EventKind::Create(_)
| EventKind::Remove(_)
| EventKind::Modify(ModifyKind::Data(_))
| EventKind::Modify(ModifyKind::Name(_))
| EventKind::Modify(ModifyKind::Any)
| EventKind::Any
);
if !kind_ok {
continue;
}
let has_scss = event.paths.iter().any(|p| {
p.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("scss"))
.unwrap_or(false)
});
if !has_scss {
continue;
}
let mut any_changed = false;
for p in &event.paths {
if let Some(mt) = file_mtime(p) {
match mtimes.get(p) {
Some(prev) if *prev == mt => continue,
_ => {
mtimes.insert(p.clone(), mt);
any_changed = true;
}
}
} else {
any_changed = true; }
}
if !any_changed {
continue;
}
if last_compile.elapsed() < Duration::from_millis(500) {
continue;
}
last_compile = Instant::now();
let file = event
.paths
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
println!(
"\n{} SCSS changed ({}) — recompiling...",
"♻".cyan(),
Path::new(&file)
.file_name()
.unwrap_or_default()
.to_string_lossy()
);
crate::scss::compile_dir(input_dir, output_dir, minify);
}
Ok(Err(e)) => {
eprintln!("{} SCSS watcher event error: {}", icon_fail().red(), e);
}
Err(e) => {
eprintln!("{} SCSS watcher channel closed: {}", icon_fail().red(), e);
break;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use notify::event::{AccessKind, CreateKind, DataChange, MetadataKind, RemoveKind};
use std::path::PathBuf;
fn ev(kind: EventKind, path: &str) -> Event {
Event {
kind,
paths: vec![PathBuf::from(path)],
attrs: Default::default(),
}
}
#[test]
fn modify_data_event_on_source_file_is_meaningful() {
let e = ev(
EventKind::Modify(ModifyKind::Data(DataChange::Any)),
"/project/src/routes/home.py",
);
assert!(is_meaningful_event(&e));
}
#[test]
fn create_event_is_meaningful() {
let e = ev(EventKind::Create(CreateKind::File), "/project/src/routes/new.py");
assert!(is_meaningful_event(&e));
}
#[test]
fn remove_event_is_meaningful() {
let e = ev(
EventKind::Remove(RemoveKind::File),
"/project/src/routes/old.py",
);
assert!(is_meaningful_event(&e));
}
#[test]
fn access_event_is_ignored() {
let e = ev(
EventKind::Access(AccessKind::Any),
"/project/src/routes/home.py",
);
assert!(!is_meaningful_event(&e));
}
#[test]
fn metadata_only_event_is_ignored() {
let e = ev(
EventKind::Modify(ModifyKind::Metadata(MetadataKind::Any)),
"/project/src/routes/home.py",
);
assert!(!is_meaningful_event(&e));
}
#[test]
fn log_file_is_ignored() {
let e = ev(
EventKind::Modify(ModifyKind::Data(DataChange::Any)),
"/project/logs/tina4.log",
);
assert!(!is_meaningful_event(&e));
}
#[test]
fn sqlite_wal_file_is_ignored() {
let e = ev(
EventKind::Modify(ModifyKind::Data(DataChange::Any)),
"/project/data/app.db-wal",
);
assert!(!is_meaningful_event(&e));
}
#[test]
fn pycache_event_is_ignored() {
let e = ev(
EventKind::Modify(ModifyKind::Data(DataChange::Any)),
"/project/src/routes/__pycache__/home.cpython-313.pyc",
);
assert!(!is_meaningful_event(&e));
}
#[test]
fn git_internal_event_is_ignored() {
let e = ev(
EventKind::Modify(ModifyKind::Data(DataChange::Any)),
"/project/.git/HEAD",
);
assert!(!is_meaningful_event(&e));
}
#[test]
fn node_modules_event_is_ignored() {
let e = ev(
EventKind::Modify(ModifyKind::Data(DataChange::Any)),
"/project/node_modules/.package-lock.json",
);
assert!(!is_meaningful_event(&e));
}
#[test]
fn swap_file_is_ignored() {
let e = ev(
EventKind::Modify(ModifyKind::Data(DataChange::Any)),
"/project/src/routes/.home.py.swp",
);
assert!(!is_meaningful_event(&e));
}
fn would_trigger_scss_compile(event: &Event) -> bool {
let kind_ok = matches!(
event.kind,
EventKind::Create(_)
| EventKind::Remove(_)
| EventKind::Modify(ModifyKind::Data(_))
| EventKind::Modify(ModifyKind::Name(_))
| EventKind::Modify(ModifyKind::Any)
| EventKind::Any
);
if !kind_ok {
return false;
}
for path in &event.paths {
let s = path.to_string_lossy();
if IGNORED_SUBSTRINGS.iter().any(|sub| s.contains(sub)) {
return false;
}
}
event.paths.iter().any(|p| {
p.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("scss"))
.unwrap_or(false)
})
}
#[test]
fn scss_file_modify_triggers_compile() {
let e = ev(
EventKind::Modify(ModifyKind::Data(DataChange::Any)),
"/project/src/scss/main.scss",
);
assert!(would_trigger_scss_compile(&e));
}
#[test]
fn scss_file_create_triggers_compile() {
let e = ev(
EventKind::Create(CreateKind::File),
"/project/src/scss/_variables.scss",
);
assert!(would_trigger_scss_compile(&e));
}
#[test]
fn non_scss_file_in_scss_dir_does_not_trigger() {
let e = ev(
EventKind::Create(CreateKind::File),
"/project/src/scss/README.md",
);
assert!(!would_trigger_scss_compile(&e));
}
#[test]
fn css_file_in_scss_dir_does_not_trigger() {
let e = ev(
EventKind::Modify(ModifyKind::Data(DataChange::Any)),
"/project/src/scss/output.css",
);
assert!(!would_trigger_scss_compile(&e));
}
#[test]
fn access_event_on_scss_does_not_trigger() {
let e = ev(
EventKind::Access(AccessKind::Any),
"/project/src/scss/main.scss",
);
assert!(!would_trigger_scss_compile(&e));
}
#[test]
fn py_file_in_src_does_not_trigger_scss() {
let e = ev(
EventKind::Create(CreateKind::File),
"/project/src/routes/api.py",
);
assert!(!would_trigger_scss_compile(&e));
}
}