ontoref-code/crates/ontoref-daemon/src/ui/drift_watcher.rs
Jesús Pérez 2d87d60bb5
Some checks failed
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
chore: add src code
2026-03-13 00:18:14 +00:00

219 lines
6.6 KiB
Rust

//! Passive drift observer.
//!
//! Watches `crates/`, `.ontology/`, and `adrs/` for changes. After a debounce
//! window, spawns `./ontoref sync scan` followed by `./ontoref sync diff`. If
//! the diff output contains MISSING, STALE, DRIFT, or BROKEN items it pushes a
//! custom notification into every registered project's notification ring
//! buffer.
//!
//! Intentionally read-only: no `apply` step is ever triggered automatically.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher};
use tokio::sync::mpsc;
use tracing::{debug, info, warn};
use crate::notifications::NotificationStore;
const DEBOUNCE: Duration = Duration::from_secs(15);
pub struct DriftWatcher {
_watcher: RecommendedWatcher,
_task: tokio::task::JoinHandle<()>,
}
impl DriftWatcher {
/// Start watching `project_root` for source and ontology changes.
///
/// Watches `crates/`, `.ontology/`, `adrs/` (and `reflection/modes/`).
/// When drift is detected after `./ontoref sync scan && sync diff`, emits
/// a custom notification via `notifications`.
pub fn start(
project_root: &Path,
project_name: String,
notifications: Arc<NotificationStore>,
) -> Result<Self, crate::error::DaemonError> {
let root = project_root.to_path_buf();
let (tx, rx) = mpsc::channel::<PathBuf>(64);
let tx2 = tx.clone();
let mut watcher = RecommendedWatcher::new(
move |res: std::result::Result<Event, notify::Error>| match res {
Ok(event) => {
for path in event.paths {
let _ = tx2.try_send(path);
}
}
Err(e) => warn!(error = %e, "drift watcher notify error"),
},
Config::default(),
)
.map_err(|e| crate::error::DaemonError::Watcher(e.to_string()))?;
// Watch relevant subtrees; ignore missing dirs gracefully.
for subdir in &["crates", ".ontology", "adrs", "reflection/modes"] {
let path = root.join(subdir);
if path.exists() {
watcher
.watch(&path, RecursiveMode::Recursive)
.map_err(|e| crate::error::DaemonError::Watcher(e.to_string()))?;
debug!(path = %path.display(), "drift watcher: watching");
}
}
info!(root = %root.display(), "drift watcher started");
let task = tokio::spawn(drift_loop(rx, root, project_name, notifications));
Ok(Self {
_watcher: watcher,
_task: task,
})
}
}
async fn drift_loop(
mut rx: mpsc::Receiver<PathBuf>,
root: PathBuf,
project: String,
notifications: Arc<NotificationStore>,
) {
loop {
// Block until first change arrives.
let Some(_first) = rx.recv().await else {
return;
};
// Drain events within debounce window.
tokio::time::sleep(DEBOUNCE).await;
while rx.try_recv().is_ok() {}
info!(
project,
"drift watcher: change detected — running scan+diff"
);
run_scan_diff(&root, &project, &notifications).await;
}
}
async fn run_scan_diff(root: &Path, project: &str, notifications: &Arc<NotificationStore>) {
let ontoref_bin = root.join("ontoref");
if !ontoref_bin.exists() {
debug!("drift watcher: ontoref binary not found, skipping scan+diff");
return;
}
// Phase 1: scan.
let scan_ok = tokio::process::Command::new(&ontoref_bin)
.args(["sync", "scan"])
.current_dir(root)
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false);
if !scan_ok {
warn!(project, "drift watcher: sync scan failed");
return;
}
// Phase 2: diff — capture stdout to detect drift.
let diff_out = match tokio::process::Command::new(&ontoref_bin)
.args(["sync", "diff"])
.current_dir(root)
.output()
.await
{
Ok(o) => o,
Err(e) => {
warn!(project, error = %e, "drift watcher: sync diff spawn failed");
return;
}
};
let stdout = String::from_utf8_lossy(&diff_out.stdout);
let stderr = String::from_utf8_lossy(&diff_out.stderr);
let combined = format!("{stdout}{stderr}");
let counts = DriftCounts::parse(&combined);
if counts.is_clean() {
debug!(project, "drift watcher: no drift detected");
return;
}
let summary = counts.summary();
info!(project, %summary, "drift watcher: ontology drift detected");
notifications.push_custom(
project,
"ontology_drift",
format!("Ontology drift detected — {summary}"),
Some(serde_json::json!({
"kind": "drift",
"counts": {
"missing": counts.missing,
"stale": counts.stale,
"drift": counts.drift,
"broken": counts.broken,
},
"hint": "Run `./ontoref sync-ontology` to review and apply patches.",
})),
Some("drift-watcher".to_string()),
None,
);
}
struct DriftCounts {
missing: usize,
stale: usize,
drift: usize,
broken: usize,
}
impl DriftCounts {
/// Parse diff output looking for MISSING / STALE / DRIFT / BROKEN markers.
///
/// The format from `./ontoref sync diff` uses these keywords as line
/// prefixes or inline labels. We count distinct occurrences.
fn parse(output: &str) -> Self {
let missing = count_marker(output, "MISSING");
let stale = count_marker(output, "STALE");
let drift = count_marker(output, "DRIFT");
let broken = count_marker(output, "BROKEN");
Self {
missing,
stale,
drift,
broken,
}
}
fn is_clean(&self) -> bool {
self.missing == 0 && self.stale == 0 && self.drift == 0 && self.broken == 0
}
fn summary(&self) -> String {
let mut parts = Vec::new();
if self.missing > 0 {
parts.push(format!("{} MISSING", self.missing));
}
if self.stale > 0 {
parts.push(format!("{} STALE", self.stale));
}
if self.drift > 0 {
parts.push(format!("{} DRIFT", self.drift));
}
if self.broken > 0 {
parts.push(format!("{} BROKEN", self.broken));
}
parts.join(", ")
}
}
fn count_marker(output: &str, marker: &str) -> usize {
output.lines().filter(|l| l.contains(marker)).count()
}