use anyhow::Result;
use camino::Utf8PathBuf;
use dictator_core::Source;
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use notify_types::event::{Event, EventKind};
use std::collections::HashSet;
use std::sync::mpsc;
use std::time::{Duration, Instant};
use crate::cli::{OutputFormat, WatchArgs};
use crate::config::{load_config, load_dictate_config};
use crate::output::{SerializableDiagnostic, byte_to_line_col, print_diagnostic};
use crate::regime::init_regime_for_watch;
const WATCHER_RX_TIMEOUT_MS: u64 = 200;
pub fn run_watch(
args: WatchArgs,
config_path: Option<Utf8PathBuf>,
profile: Option<String>,
) -> Result<()> {
let cfg = load_config(config_path.as_ref())?;
let format = if args.json {
OutputFormat::Json
} else {
cfg.format.unwrap_or(OutputFormat::Human)
};
let decree_config = load_dictate_config(config_path.as_ref(), profile.as_deref())?;
let mut regime = init_regime_for_watch(decree_config.as_ref());
if let Some(ref config) = decree_config {
for (name, settings) in &config.decree {
if matches!(
name.as_str(),
"supreme" | "ruby" | "typescript" | "golang" | "rust" | "python" | "frontmatter"
) {
continue;
}
if let Some(ref path) = settings.path
&& settings.enabled.unwrap_or(true)
{
regime.add_wasm_decree(path)?;
}
}
}
#[cfg(feature = "wasm-loader")]
for p in &args.plugin {
regime.add_wasm_decree(p)?;
}
let watched_exts = regime.watched_extensions();
let (tx, rx) = mpsc::channel();
let mut watcher: RecommendedWatcher =
notify::recommended_watcher(move |res: notify::Result<Event>| {
if let Ok(event) = res {
tx.send(event).ok();
}
})?;
for path in &args.paths {
watcher.watch(path.as_std_path(), RecursiveMode::Recursive)?;
}
println!("Watching {} path(s) for code changes...", args.paths.len());
let running = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
{
let running = running.clone();
ctrlc::set_handler(move || {
running.store(false, std::sync::atomic::Ordering::SeqCst);
})?;
}
let mut last_run = Instant::now();
while running.load(std::sync::atomic::Ordering::SeqCst) {
if let Ok(event) = rx.recv_timeout(Duration::from_millis(WATCHER_RX_TIMEOUT_MS)) {
if !is_relevant(&event) {
continue;
}
if last_run.elapsed() < Duration::from_millis(args.debounce_ms / 2) {
continue;
}
let mut files = Vec::new();
for path in &event.paths {
let Ok(p) = Utf8PathBuf::from_path_buf(path.to_owned()) else {
continue;
};
let ext = p.extension().map(str::to_ascii_lowercase);
if let Some(ref allowed) = watched_exts {
let Some(ext) = ext else {
continue;
};
if !allowed.contains(ext.as_str()) {
continue;
}
}
files.push(p);
}
if files.is_empty() {
continue;
}
let mut exit_code = 0;
let mut json_out: Vec<SerializableDiagnostic> = Vec::new();
for path in files {
if !std::path::Path::new(path.as_str()).exists() {
continue;
}
let Some(text) = crate::files::read_source_file(path.as_std_path()) else {
continue;
};
let path_ref = path.as_path();
let source = Source {
path: path_ref,
text: &text,
};
let diags = regime.enforce(&[source])?;
let mut seen = HashSet::new();
for diag in diags {
let key = (
path_ref.as_str().to_string(),
diag.span.start,
diag.span.end,
diag.rule.clone(),
diag.message.clone(),
diag.enforced,
);
if !seen.insert(key) {
continue;
}
match format {
OutputFormat::Human => {
print_diagnostic(path_ref.as_str(), &text, &diag);
}
OutputFormat::Json => {
let (line, col) = byte_to_line_col(&text, diag.span.start);
json_out.push(SerializableDiagnostic {
path: path_ref.as_str().to_string(),
line,
col,
rule: diag.rule.clone(),
message: diag.message.clone(),
enforced: diag.enforced,
span: diag.span,
});
}
}
exit_code = 1;
}
}
if matches!(format, OutputFormat::Json) {
println!("{}", serde_json::to_string_pretty(&json_out)?);
}
if exit_code != 0 {
println!("lint failures (exit {exit_code})");
}
last_run = Instant::now();
}
}
Ok(())
}
const fn is_relevant(event: &Event) -> bool {
matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_))
}