Skip to main content

vtcode_core/tools/
edited_file_monitor.rs

1use crate::audit::{FileConflictAuditEvent, FileConflictAuditLog};
2use crate::config::PermissionsConfig;
3use crate::tools::file_ops::{build_diff_preview, diff_preview_error_skip};
4use anyhow::{Context, Result};
5use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
6use parking_lot::Mutex;
7use serde_json::{Value, json};
8use std::collections::{HashMap, HashSet, VecDeque};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::sync::Mutex as StdMutex;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
14use std::thread;
15use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
16use tokio::sync::Notify;
17use vtcode_commons::utils::calculate_sha256;
18use vtcode_commons::{canonicalize, workspace_relative_display};
19
20pub const FILE_CONFLICT_OVERRIDE_ARG: &str = "__vtcode_conflict_override";
21pub const FILE_CONFLICT_DETECTED_FIELD: &str = "conflict_detected";
22pub const FILE_CONFLICT_PATH_FIELD: &str = "conflict_path";
23
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct FileSnapshot {
26    pub exists: bool,
27    pub size_bytes: u64,
28    pub modified_millis: Option<u128>,
29    pub sha256: String,
30    pub text_content: Option<String>,
31}
32
33impl FileSnapshot {
34    fn fingerprint(&self) -> String {
35        let modified = self
36            .modified_millis
37            .map(|value| value.to_string())
38            .unwrap_or_else(|| "none".to_string());
39        format!("exists={};size={};modified={};sha256={}", self.exists, self.size_bytes, modified, self.sha256)
40    }
41
42    fn to_json(&self) -> Value {
43        json!({
44            "exists": self.exists,
45            "size_bytes": self.size_bytes,
46            "modified_millis": self.modified_millis,
47            "sha256": self.sha256,
48        })
49    }
50
51    fn same_contents(&self, other: &Self) -> bool {
52        self.exists == other.exists && self.size_bytes == other.size_bytes && self.sha256 == other.sha256
53    }
54
55    fn same_identity(&self, other: &Self) -> bool {
56        self.same_contents(other) && self.modified_millis == other.modified_millis
57    }
58
59    fn from_identity_value(value: &Value) -> Option<Self> {
60        let object = value.as_object()?;
61        let modified_millis = match object.get("modified_millis") {
62            Some(Value::Null) | None => None,
63            Some(value) => value.as_u64().map(u128::from).or_else(|| {
64                value.as_i64().filter(|millis| *millis >= 0).map(|millis| {
65                    #[allow(
66                        clippy::cast_sign_loss,
67                        reason = "Intentional compatibility, platform, or test-only suppression."
68                    )]
69                    let val: u64 = millis as u64;
70                    val as u128
71                }) // safe: filtered non-negative
72            }),
73        };
74
75        Some(Self {
76            exists: object.get("exists")?.as_bool()?,
77            size_bytes: object.get("size_bytes")?.as_u64()?,
78            modified_millis,
79            sha256: object.get("sha256")?.as_str()?.to_string(),
80            text_content: None,
81        })
82    }
83
84    fn from_text_content(content: &str) -> Self {
85        let bytes = content.as_bytes();
86        let sha256 = calculate_sha256(bytes);
87
88        Self {
89            exists: true,
90            size_bytes: bytes.len() as u64,
91            modified_millis: None,
92            sha256,
93            text_content: Some(content.to_string()),
94        }
95    }
96
97    fn missing() -> Self {
98        Self {
99            exists: false,
100            size_bytes: 0,
101            modified_millis: None,
102            sha256: String::new(),
103            text_content: None,
104        }
105    }
106}
107
108#[derive(Clone, Debug)]
109pub struct FileConflict {
110    pub path: PathBuf,
111    pub read_snapshot: Option<FileSnapshot>,
112    pub disk_snapshot: Option<FileSnapshot>,
113    pub intended_content: Option<String>,
114    pub emit_hitl_notification: bool,
115}
116
117#[derive(Clone, Debug, PartialEq, Eq)]
118pub struct TrackedPathFreshness {
119    pub path: PathBuf,
120    pub is_stale: bool,
121    pub fingerprint: Option<String>,
122}
123
124impl FileConflict {
125    pub fn to_tool_output(&self, workspace_root: &Path) -> Value {
126        let display_path = workspace_relative_display(workspace_root, &self.path);
127        let disk_content = self.disk_snapshot.as_ref().and_then(|snapshot| snapshot.text_content.clone());
128        let diff_preview = match (&disk_content, &self.intended_content) {
129            (Some(before), Some(after)) => build_diff_preview(&display_path, Some(before), after),
130            (None, Some(_)) => diff_preview_error_skip("binary_or_non_utf8_disk_content", None),
131            _ => diff_preview_error_skip("missing_intended_content", None),
132        };
133
134        json!({
135            "success": true,
136            FILE_CONFLICT_DETECTED_FIELD: true,
137            FILE_CONFLICT_PATH_FIELD: display_path,
138            "message": "File changed on disk since the agent last read it.",
139            "resolution": "pending",
140            "emit_hitl_notification": self.emit_hitl_notification,
141            "disk_content": disk_content,
142            "intended_content": self.intended_content,
143            "read_snapshot": self.read_snapshot.as_ref().map(FileSnapshot::to_json),
144            "disk_snapshot": self.disk_snapshot.as_ref().map(FileSnapshot::to_json),
145            "diff_preview": diff_preview,
146        })
147    }
148}
149
150#[derive(Clone, Debug)]
151struct StaleConflictState {
152    notification_emitted: bool,
153}
154
155#[derive(Clone, Debug)]
156struct TrackedFileState {
157    last_read_snapshot: Option<FileSnapshot>,
158    last_known_disk_snapshot: Option<FileSnapshot>,
159    last_agent_write_snapshot: Option<FileSnapshot>,
160    active_mutation: Option<u64>,
161    pending_mutations: VecDeque<u64>,
162    stale_conflict: Option<StaleConflictState>,
163    notify: Arc<Notify>,
164}
165
166impl TrackedFileState {
167    fn new() -> Self {
168        Self {
169            last_read_snapshot: None,
170            last_known_disk_snapshot: None,
171            last_agent_write_snapshot: None,
172            active_mutation: None,
173            pending_mutations: VecDeque::new(),
174            stale_conflict: None,
175            notify: Arc::new(Notify::new()),
176        }
177    }
178}
179
180#[derive(Default)]
181struct MonitorState {
182    tracked_files: HashMap<PathBuf, TrackedFileState>,
183    watched_parents: HashSet<PathBuf>,
184}
185
186impl MonitorState {
187    fn tracked_entry(&mut self, path: &Path) -> &mut TrackedFileState {
188        self.tracked_files
189            .entry(path.to_path_buf())
190            .or_insert_with(TrackedFileState::new)
191    }
192}
193
194struct EditedFileMonitorInner {
195    state: Mutex<MonitorState>,
196    watcher: StdMutex<Option<RecommendedWatcher>>,
197    audit_log: Mutex<Option<FileConflictAuditLog>>,
198    next_mutation_id: AtomicU64,
199    debounce_duration: Duration,
200    inspection: crate::tools::source_inspection::SourceInspection,
201}
202
203#[derive(Clone)]
204pub struct EditedFileMonitor {
205    inner: Arc<EditedFileMonitorInner>,
206}
207
208pub struct MutationLease {
209    inner: Arc<EditedFileMonitorInner>,
210    path: PathBuf,
211    mutation_id: u64,
212    released: bool,
213}
214
215struct PendingMutationGuard {
216    inner: Arc<EditedFileMonitorInner>,
217    path: PathBuf,
218    mutation_id: u64,
219    armed: bool,
220}
221
222impl PendingMutationGuard {
223    fn new(inner: Arc<EditedFileMonitorInner>, path: PathBuf, mutation_id: u64) -> Self {
224        Self { inner, path, mutation_id, armed: true }
225    }
226
227    fn disarm(&mut self) {
228        self.armed = false;
229    }
230}
231
232impl Drop for PendingMutationGuard {
233    fn drop(&mut self) {
234        if !self.armed {
235            return;
236        }
237
238        let mut state = self.inner.state.lock();
239        let Some(entry) = state.tracked_files.get_mut(&self.path) else {
240            return;
241        };
242
243        if remove_pending_mutation(entry, self.mutation_id) {
244            entry.notify.notify_waiters();
245        }
246    }
247}
248
249impl MutationLease {
250    pub fn path(&self) -> &Path {
251        &self.path
252    }
253}
254
255impl Drop for MutationLease {
256    fn drop(&mut self) {
257        if self.released {
258            return;
259        }
260        let mut state = self.inner.state.lock();
261        if let Some(entry) = state.tracked_files.get_mut(&self.path) {
262            let mut released_active = false;
263            if entry.active_mutation == Some(self.mutation_id) {
264                entry.active_mutation = None;
265                released_active = true;
266            }
267            let removed_pending = remove_pending_mutation(entry, self.mutation_id);
268            if released_active || removed_pending {
269                entry.notify.notify_waiters();
270            }
271        }
272        self.released = true;
273    }
274}
275
276impl EditedFileMonitor {
277    pub fn source_inspection(&self) -> &crate::tools::source_inspection::SourceInspection {
278        &self.inner.inspection
279    }
280
281    pub fn new() -> Self {
282        let (event_tx, event_rx) = mpsc::channel::<PathBuf>();
283        let watcher = RecommendedWatcher::new(
284            move |event: notify::Result<notify::Event>| {
285                let Ok(event) = event else {
286                    return;
287                };
288
289                if !matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)) {
290                    return;
291                }
292
293                for path in event.paths {
294                    let _ = event_tx.send(path);
295                }
296            },
297            notify::Config::default(),
298        )
299        .ok();
300
301        let inner = Arc::new(EditedFileMonitorInner {
302            state: Mutex::new(MonitorState::default()),
303            watcher: StdMutex::new(watcher),
304            audit_log: Mutex::new(None),
305            next_mutation_id: AtomicU64::new(1),
306            debounce_duration: Duration::from_millis(250),
307            inspection: Default::default(),
308        });
309
310        let monitor = Self { inner: Arc::clone(&inner) };
311        spawn_event_loop(inner, event_rx);
312        monitor
313    }
314
315    pub fn apply_permissions_config(&self, permissions: &PermissionsConfig) {
316        let mut audit_log = self.inner.audit_log.lock();
317        if !permissions.audit_enabled {
318            *audit_log = None;
319            return;
320        }
321
322        let audit_dir = vtcode_commons::paths::expand_tilde(&permissions.audit_directory);
323        match FileConflictAuditLog::new(audit_dir) {
324            Ok(log) => *audit_log = Some(log),
325            Err(err) => {
326                tracing::warn!(error = %err, "Failed to initialize file conflict audit log");
327                *audit_log = None;
328            }
329        }
330    }
331
332    pub async fn track_read(&self, path: &Path) -> Result<()> {
333        let path = normalize_event_path(path);
334        let snapshot = snapshot_path_async(path.clone()).await?;
335        self.record_read_snapshot(&path, snapshot)
336    }
337
338    pub async fn accept_disk_version(&self, path: &Path) -> Result<()> {
339        let path = normalize_event_path(path);
340        let snapshot = snapshot_path_async(path.clone()).await?;
341        self.record_read_snapshot(&path, snapshot)
342    }
343
344    pub fn record_read_snapshot(&self, path: &Path, snapshot: FileSnapshot) -> Result<()> {
345        let path = normalize_event_path(path);
346        {
347            let mut state = self.inner.state.lock();
348            let entry = state.tracked_entry(&path);
349            entry.last_read_snapshot = Some(snapshot.clone());
350            entry.last_known_disk_snapshot = Some(snapshot);
351            entry.stale_conflict = None;
352        }
353        self.watch_parent(&path);
354        Ok(())
355    }
356
357    pub fn record_read_text(&self, path: &Path, content: &str) -> Result<()> {
358        self.record_read_snapshot(path, FileSnapshot::from_text_content(content))
359    }
360
361    pub fn record_agent_write_snapshot(&self, path: &Path, snapshot: FileSnapshot) -> Result<()> {
362        let path = normalize_event_path(path);
363        {
364            let mut state = self.inner.state.lock();
365            let entry = state.tracked_entry(&path);
366            let snap_for_disk = snapshot.clone();
367            entry.last_read_snapshot = Some(snap_for_disk);
368            entry.last_known_disk_snapshot = Some(snapshot.clone());
369            entry.last_agent_write_snapshot = Some(snapshot);
370            entry.stale_conflict = None;
371        }
372        self.watch_parent(&path);
373        Ok(())
374    }
375
376    pub(crate) async fn record_agent_write(&self, path: &Path) -> Result<()> {
377        let root = normalize_event_path(path);
378        let mut paths = self
379            .tracked_paths()
380            .into_iter()
381            .filter(|path| path.starts_with(&root))
382            .collect::<Vec<_>>();
383        paths.push(root);
384        self.record_agent_paths(paths).await
385    }
386
387    pub(crate) async fn record_agent_move(&self, source: &Path, destination: &Path) -> Result<()> {
388        let source = normalize_event_path(source);
389        let destination = normalize_event_path(destination);
390        let mut paths = vec![source.clone(), destination.clone()];
391        for path in self.tracked_paths() {
392            if let Ok(relative) = path.strip_prefix(&source) {
393                paths.push(destination.join(relative));
394                paths.push(path);
395            } else if path.starts_with(&destination) {
396                paths.push(path);
397            }
398        }
399        self.record_agent_paths(paths).await
400    }
401
402    async fn record_agent_paths(&self, mut paths: Vec<PathBuf>) -> Result<()> {
403        paths.sort();
404        paths.dedup();
405        for path in paths {
406            let snapshot = snapshot_path_async(path.clone()).await?;
407            self.record_agent_write_snapshot(&path, snapshot)?;
408        }
409        Ok(())
410    }
411
412    pub fn record_agent_write_text(&self, path: &Path, content: &str) -> Result<()> {
413        self.record_agent_write_snapshot(path, FileSnapshot::from_text_content(content))
414    }
415
416    pub fn record_agent_removal(&self, path: &Path) -> Result<()> {
417        self.record_agent_write_snapshot(path, FileSnapshot::missing())
418    }
419
420    pub async fn tracked_read_text(&self, path: &Path) -> Option<String> {
421        let path = normalize_event_path(path);
422        let state = self.inner.state.lock();
423        state
424            .tracked_files
425            .get(&path)
426            .and_then(|entry| entry.last_read_snapshot.as_ref())
427            .and_then(|snapshot| snapshot.text_content.clone())
428    }
429
430    pub fn tracked_paths(&self) -> Vec<PathBuf> {
431        let state = self.inner.state.lock();
432        let mut paths = state.tracked_files.keys().cloned().collect::<Vec<_>>();
433        paths.sort();
434        paths
435    }
436
437    pub fn stale_tracked_paths(&self) -> Vec<PathBuf> {
438        self.tracked_path_freshness()
439            .into_iter()
440            .filter_map(|entry| entry.is_stale.then_some(entry.path))
441            .collect()
442    }
443
444    pub fn tracked_path_freshness(&self) -> Vec<TrackedPathFreshness> {
445        let state = self.inner.state.lock();
446        let mut entries = state
447            .tracked_files
448            .iter()
449            .map(|(path, entry)| {
450                let is_stale = entry
451                    .last_read_snapshot
452                    .as_ref()
453                    .zip(entry.last_known_disk_snapshot.as_ref())
454                    .is_some_and(|(read_snapshot, disk_snapshot)| !read_snapshot.same_contents(disk_snapshot));
455                TrackedPathFreshness {
456                    path: path.clone(),
457                    is_stale,
458                    fingerprint: entry.last_known_disk_snapshot.as_ref().map(FileSnapshot::fingerprint),
459                }
460            })
461            .collect::<Vec<_>>();
462        entries.sort_by(|left, right| left.path.cmp(&right.path));
463        entries
464    }
465
466    pub fn tracked_path_fingerprint(&self, path: &Path) -> Option<String> {
467        let path = normalize_event_path(path);
468        let state = self.inner.state.lock();
469        state
470            .tracked_files
471            .get(&path)
472            .and_then(|entry| entry.last_known_disk_snapshot.as_ref())
473            .map(FileSnapshot::fingerprint)
474    }
475
476    pub async fn acquire_mutation(&self, path: &Path) -> MutationLease {
477        let path = normalize_event_path(path);
478        let mutation_id = self.inner.next_mutation_id.fetch_add(1, Ordering::SeqCst);
479        let mut pending_guard = PendingMutationGuard::new(Arc::clone(&self.inner), path.clone(), mutation_id);
480
481        loop {
482            let notify: Arc<Notify>;
483            let waiter = {
484                let mut state = self.inner.state.lock();
485                let entry = state.tracked_entry(&path);
486
487                if entry.active_mutation.is_none() {
488                    if let Some(front) = entry.pending_mutations.front() {
489                        if *front == mutation_id {
490                            let _ = entry.pending_mutations.pop_front();
491                            entry.active_mutation = Some(mutation_id);
492                            pending_guard.disarm();
493                            return MutationLease {
494                                inner: Arc::clone(&self.inner),
495                                path,
496                                mutation_id,
497                                released: false,
498                            };
499                        }
500                    } else {
501                        entry.active_mutation = Some(mutation_id);
502                        pending_guard.disarm();
503                        return MutationLease {
504                            inner: Arc::clone(&self.inner),
505                            path,
506                            mutation_id,
507                            released: false,
508                        };
509                    }
510                }
511
512                if !entry.pending_mutations.iter().any(|pending_id| *pending_id == mutation_id) {
513                    entry.pending_mutations.push_back(mutation_id);
514                }
515
516                notify = entry.notify.clone();
517                // Register waiter interest while still holding the state lock.
518                // Release paths call `notify_waiters()` under the same lock,
519                // so a release can neither slip between the condition check
520                // above and this registration (lost wakeup -> hang) nor wake
521                // a future that is never awaited.
522                let mut waiter = Box::pin(notify.notified());
523                waiter.as_mut().enable();
524                waiter
525            };
526
527            waiter.await;
528        }
529    }
530
531    pub async fn detect_conflict(
532        &self,
533        path: &Path,
534        intended_content: Option<String>,
535        approved_snapshot: Option<FileSnapshot>,
536    ) -> Result<Option<FileConflict>> {
537        let path = normalize_event_path(path);
538        let current_snapshot = snapshot_path_async(path.clone()).await?;
539        let mut should_audit = false;
540
541        let maybe_conflict = {
542            let mut state = self.inner.state.lock();
543            let entry = state.tracked_entry(&path);
544            entry.last_known_disk_snapshot = Some(current_snapshot.clone());
545
546            if entry
547                .last_agent_write_snapshot
548                .as_ref()
549                .is_some_and(|snapshot| snapshot.same_contents(&current_snapshot))
550            {
551                entry.last_agent_write_snapshot = None;
552                entry.last_read_snapshot = Some(current_snapshot);
553                entry.stale_conflict = None;
554                return Ok(None);
555            }
556
557            let Some(read_snapshot) = entry.last_read_snapshot.clone() else {
558                return Ok(None);
559            };
560
561            if read_snapshot.same_contents(&current_snapshot) {
562                entry.stale_conflict = None;
563                return Ok(None);
564            }
565
566            if approved_snapshot
567                .as_ref()
568                .is_some_and(|snapshot| snapshot.same_identity(&current_snapshot))
569            {
570                entry.stale_conflict = None;
571                return Ok(None);
572            }
573
574            let emit_hitl_notification = match entry.stale_conflict.as_mut() {
575                Some(existing) => {
576                    let emit = !existing.notification_emitted;
577                    existing.notification_emitted = true;
578                    emit
579                }
580                None => {
581                    entry.stale_conflict = Some(StaleConflictState { notification_emitted: true });
582                    should_audit = true;
583                    true
584                }
585            };
586
587            Some(FileConflict {
588                path: path.clone(),
589                read_snapshot: Some(read_snapshot),
590                disk_snapshot: Some(current_snapshot.clone()),
591                intended_content,
592                emit_hitl_notification,
593            })
594        };
595
596        if should_audit {
597            self.record_conflict_audit(&path, &current_snapshot, "pre_write_conflict");
598        }
599
600        Ok(maybe_conflict)
601    }
602
603    #[cfg(test)]
604    pub async fn debug_process_path_change(&self, path: &Path) -> Result<()> {
605        self.process_path_change(path.to_path_buf())
606    }
607
608    fn watch_parent(&self, path: &Path) {
609        let Some(parent) = path.parent().map(Path::to_path_buf) else {
610            return;
611        };
612
613        let should_watch = {
614            let mut state = self.inner.state.lock();
615            state.watched_parents.insert(parent.clone())
616        };
617
618        if !should_watch {
619            return;
620        }
621
622        let Ok(mut watcher) = self.inner.watcher.lock() else {
623            return;
624        };
625        let Some(watcher) = watcher.as_mut() else {
626            return;
627        };
628
629        if let Err(err) = watcher.watch(&parent, RecursiveMode::NonRecursive) {
630            tracing::warn!(path = %parent.display(), error = %err, "Failed to watch edited-file parent directory");
631        }
632    }
633
634    fn record_conflict_audit(&self, path: &Path, snapshot: &FileSnapshot, reason: &str) {
635        let event = FileConflictAuditEvent {
636            timestamp: chrono::Local::now(),
637            path: path.to_path_buf(),
638            reason: reason.to_string(),
639            file_exists: snapshot.exists,
640            size_bytes: snapshot.exists.then_some(snapshot.size_bytes),
641            sha256: snapshot.exists.then_some(snapshot.sha256.clone()),
642        };
643
644        if let Some(log) = self.inner.audit_log.lock().as_mut()
645            && let Err(err) = log.record(&event)
646        {
647            tracing::warn!(error = %err, "Failed to record file conflict audit event");
648        }
649    }
650
651    fn process_path_change(&self, path: PathBuf) -> Result<()> {
652        let tracked_path = normalize_event_path(&path);
653        let snapshot = snapshot_path_sync(&tracked_path)
654            .with_context(|| format!("Failed to snapshot externally modified file {}", tracked_path.display()))?;
655
656        let mut should_audit = false;
657
658        {
659            let mut state = self.inner.state.lock();
660            let Some(entry) = state.tracked_files.get_mut(&tracked_path) else {
661                return Ok(());
662            };
663
664            if entry
665                .last_known_disk_snapshot
666                .as_ref()
667                .is_some_and(|known| known.same_contents(&snapshot))
668            {
669                return Ok(());
670            }
671
672            entry.last_known_disk_snapshot = Some(snapshot.clone());
673
674            if entry
675                .last_agent_write_snapshot
676                .as_ref()
677                .is_some_and(|known| known.same_contents(&snapshot))
678            {
679                entry.last_read_snapshot = Some(snapshot);
680                entry.last_agent_write_snapshot = None;
681                entry.stale_conflict = None;
682                return Ok(());
683            }
684
685            if entry
686                .last_read_snapshot
687                .as_ref()
688                .is_some_and(|known| known.same_contents(&snapshot))
689            {
690                entry.stale_conflict = None;
691                return Ok(());
692            }
693
694            if entry
695                .last_read_snapshot
696                .as_ref()
697                .is_some_and(|read_snapshot| !read_snapshot.same_contents(&snapshot))
698            {
699                should_audit = true;
700                match entry.stale_conflict.as_mut() {
701                    Some(_) => {}
702                    None => {
703                        entry.stale_conflict = Some(StaleConflictState { notification_emitted: false });
704                    }
705                }
706            }
707        }
708
709        if should_audit {
710            self.record_conflict_audit(&tracked_path, &snapshot, "watcher_detected_external_change");
711        }
712
713        Ok(())
714    }
715}
716
717impl Default for EditedFileMonitor {
718    fn default() -> Self {
719        Self::new()
720    }
721}
722
723fn spawn_event_loop(inner: Arc<EditedFileMonitorInner>, event_rx: Receiver<PathBuf>) {
724    thread::spawn(move || {
725        let mut pending = HashMap::<PathBuf, Instant>::new();
726
727        loop {
728            match event_rx.recv_timeout(Duration::from_millis(50)) {
729                Ok(path) => {
730                    pending.insert(normalize_event_path(&path), Instant::now());
731                }
732                Err(RecvTimeoutError::Timeout) => {}
733                Err(RecvTimeoutError::Disconnected) => break,
734            }
735
736            let now = Instant::now();
737            let ready = pending
738                .iter()
739                .filter_map(|(path, observed_at)| {
740                    (now.duration_since(*observed_at) >= inner.debounce_duration).then_some(path.clone())
741                })
742                .collect::<Vec<_>>();
743
744            for path in ready {
745                pending.remove(&path);
746                let monitor = EditedFileMonitor { inner: Arc::clone(&inner) };
747                if let Err(err) = monitor.process_path_change(path.clone()) {
748                    tracing::debug!(path = %path.display(), error = %err, "Edited-file watcher refresh failed");
749                }
750            }
751        }
752    });
753}
754
755fn normalize_event_path(path: &Path) -> PathBuf {
756    canonicalize(path).unwrap_or_else(|_| {
757        path.parent()
758            .and_then(|parent| canonicalize(parent).ok())
759            .and_then(|parent| path.file_name().map(|name| parent.join(name)))
760            .unwrap_or_else(|| path.to_path_buf())
761    })
762}
763
764async fn snapshot_path_async(path: PathBuf) -> Result<FileSnapshot> {
765    tokio::task::spawn_blocking(move || snapshot_path_sync(&path))
766        .await
767        .context("Failed to join file snapshot task")?
768}
769
770fn snapshot_path_sync(path: &Path) -> Result<FileSnapshot> {
771    let metadata = match std::fs::metadata(path) {
772        Ok(metadata) => metadata,
773        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
774            return Ok(FileSnapshot {
775                exists: false,
776                size_bytes: 0,
777                modified_millis: None,
778                sha256: String::new(),
779                text_content: None,
780            });
781        }
782        Err(err) => {
783            return Err(err).with_context(|| format!("Failed to read metadata for {}", path.display()));
784        }
785    };
786
787    if !metadata.is_file() {
788        return Ok(FileSnapshot {
789            exists: false,
790            size_bytes: 0,
791            modified_millis: metadata.modified().ok().and_then(system_time_to_millis),
792            sha256: String::new(),
793            text_content: None,
794        });
795    }
796
797    let bytes = std::fs::read(path).with_context(|| format!("Failed to read file bytes for {}", path.display()))?;
798    let sha256 = calculate_sha256(&bytes);
799
800    Ok(FileSnapshot {
801        exists: true,
802        size_bytes: metadata.len(),
803        modified_millis: metadata.modified().ok().and_then(system_time_to_millis),
804        sha256,
805        text_content: String::from_utf8(bytes).ok(),
806    })
807}
808
809fn system_time_to_millis(time: SystemTime) -> Option<u128> {
810    time.duration_since(UNIX_EPOCH).ok().map(|duration| duration.as_millis())
811}
812
813pub fn conflict_override_snapshot(args: &Value) -> Option<FileSnapshot> {
814    args.get(FILE_CONFLICT_OVERRIDE_ARG).and_then(FileSnapshot::from_identity_value)
815}
816
817fn remove_pending_mutation(entry: &mut TrackedFileState, mutation_id: u64) -> bool {
818    let Some(index) = entry.pending_mutations.iter().position(|pending_id| *pending_id == mutation_id) else {
819        return false;
820    };
821
822    let _ = entry.pending_mutations.remove(index);
823    true
824}
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829    use tempfile::TempDir;
830
831    #[tokio::test]
832    async fn detects_same_mtime_same_size_hash_mismatch() -> Result<()> {
833        let temp = TempDir::new()?;
834        let file = temp.path().join("sample.txt");
835        std::fs::write(&file, "before\n")?;
836
837        let first = snapshot_path_async(file.clone()).await?;
838        std::fs::write(&file, "after!\n")?;
839        let second = snapshot_path_async(file.clone()).await?;
840
841        assert_eq!(first.size_bytes, second.size_bytes);
842        assert_ne!(first.sha256, second.sha256);
843        Ok(())
844    }
845
846    #[tokio::test]
847    async fn suppresses_self_write_event() -> Result<()> {
848        let temp = TempDir::new()?;
849        let file = temp.path().join("sample.txt");
850        std::fs::write(&file, "before\n")?;
851        let monitor = EditedFileMonitor::new();
852
853        monitor.track_read(&file).await?;
854        std::fs::write(&file, "after\n")?;
855        monitor.record_agent_write_text(&file, "after\n")?;
856        monitor.debug_process_path_change(&file).await?;
857
858        assert!(
859            monitor
860                .detect_conflict(&file, Some("after\n".to_string()), None)
861                .await?
862                .is_none()
863        );
864        Ok(())
865    }
866
867    #[tokio::test]
868    async fn expected_agent_snapshot_does_not_hide_external_follow_up_change() -> Result<()> {
869        let temp = TempDir::new()?;
870        let file = temp.path().join("sample.txt");
871        std::fs::write(&file, "before\n")?;
872        let monitor = EditedFileMonitor::new();
873
874        monitor.track_read(&file).await?;
875        std::fs::write(&file, "after\n")?;
876        monitor.record_agent_write_text(&file, "after\n")?;
877
878        std::fs::write(&file, "after formatted\n")?;
879        monitor.debug_process_path_change(&file).await?;
880
881        let conflict = monitor.detect_conflict(&file, Some("agent\n".to_string()), None).await?;
882        assert!(conflict.is_some());
883        Ok(())
884    }
885
886    #[tokio::test]
887    async fn queues_mutations_in_order() -> Result<()> {
888        let temp = TempDir::new()?;
889        let file = temp.path().join("sample.txt");
890        std::fs::write(&file, "hello\n")?;
891        let monitor = Arc::new(EditedFileMonitor::new());
892        let normalized_file = normalize_event_path(&file);
893
894        let first = monitor.acquire_mutation(&file).await;
895        let monitor_clone = Arc::clone(&monitor);
896        let file_clone = file.clone();
897        let waiter = tokio::spawn(async move {
898            let lease = monitor_clone.acquire_mutation(&file_clone).await;
899            lease.path().to_path_buf()
900        });
901
902        tokio::time::sleep(Duration::from_millis(50)).await;
903        drop(first);
904        let acquired = waiter.await?;
905        assert_eq!(acquired, normalized_file);
906        Ok(())
907    }
908
909    #[tokio::test]
910    async fn concurrent_mutation_waiters_all_acquire_without_stall() -> Result<()> {
911        let temp = TempDir::new()?;
912        let file = temp.path().join("sample.txt");
913        std::fs::write(&file, "hello\n")?;
914        let monitor = Arc::new(EditedFileMonitor::new());
915        let normalized_file = normalize_event_path(&file);
916
917        let first = monitor.acquire_mutation(&file).await;
918        let mut waiters = Vec::new();
919        for _ in 0..8 {
920            let monitor_clone = Arc::clone(&monitor);
921            let file_clone = file.clone();
922            waiters.push(tokio::spawn(async move {
923                let lease = monitor_clone.acquire_mutation(&file_clone).await;
924                lease.path().to_path_buf()
925            }));
926        }
927
928        // Let every waiter park inside `acquire_mutation` so the release below
929        // must wake a registered waiter. A lost wakeup would stall here.
930        tokio::time::sleep(Duration::from_millis(100)).await;
931        drop(first);
932
933        let acquired = tokio::time::timeout(Duration::from_secs(10), async {
934            let mut paths = Vec::new();
935            for waiter in waiters {
936                paths.push(waiter.await?);
937            }
938            Ok::<_, anyhow::Error>(paths)
939        })
940        .await??;
941        assert_eq!(acquired.len(), 8);
942        for path in &acquired {
943            assert_eq!(*path, normalized_file);
944        }
945        Ok(())
946    }
947
948    #[tokio::test]
949    async fn detects_external_change_after_read() -> Result<()> {
950        let temp = TempDir::new()?;
951        let file = temp.path().join("sample.txt");
952        std::fs::write(&file, "before\n")?;
953        let monitor = EditedFileMonitor::new();
954
955        monitor.track_read(&file).await?;
956        std::fs::write(&file, "external\n")?;
957        monitor.debug_process_path_change(&file).await?;
958
959        let conflict = monitor.detect_conflict(&file, Some("agent\n".to_string()), None).await?;
960        assert!(conflict.is_some());
961        Ok(())
962    }
963
964    #[tokio::test]
965    async fn cancelled_waiter_does_not_block_following_mutation() -> Result<()> {
966        let temp = TempDir::new()?;
967        let file = temp.path().join("sample.txt");
968        std::fs::write(&file, "hello\n")?;
969        let monitor = Arc::new(EditedFileMonitor::new());
970        let normalized_file = normalize_event_path(&file);
971
972        let first = monitor.acquire_mutation(&file).await;
973
974        let pending_monitor = Arc::clone(&monitor);
975        let pending_file = file.clone();
976        let pending = tokio::spawn(async move {
977            let _lease = pending_monitor.acquire_mutation(&pending_file).await;
978        });
979        tokio::time::sleep(Duration::from_millis(50)).await;
980        pending.abort();
981
982        let next_monitor = Arc::clone(&monitor);
983        let next_file = file.clone();
984        let next = tokio::spawn(async move {
985            let lease = next_monitor.acquire_mutation(&next_file).await;
986            lease.path().to_path_buf()
987        });
988
989        drop(first);
990
991        let acquired = tokio::time::timeout(Duration::from_secs(1), next).await??;
992        assert_eq!(acquired, normalized_file);
993        Ok(())
994    }
995
996    #[tokio::test]
997    async fn clears_conflict_state_when_disk_returns_to_read_snapshot() -> Result<()> {
998        let temp = TempDir::new()?;
999        let file = temp.path().join("sample.txt");
1000        std::fs::write(&file, "before\n")?;
1001        let monitor = EditedFileMonitor::new();
1002
1003        monitor.track_read(&file).await?;
1004        std::fs::write(&file, "external one\n")?;
1005        monitor.debug_process_path_change(&file).await?;
1006
1007        let first_conflict = monitor
1008            .detect_conflict(&file, Some("agent\n".to_string()), None)
1009            .await?
1010            .expect("expected initial conflict");
1011        assert!(first_conflict.emit_hitl_notification);
1012
1013        std::fs::write(&file, "before\n")?;
1014        monitor.debug_process_path_change(&file).await?;
1015        assert!(
1016            monitor
1017                .detect_conflict(&file, Some("agent\n".to_string()), None)
1018                .await?
1019                .is_none()
1020        );
1021
1022        std::fs::write(&file, "external two\n")?;
1023        monitor.debug_process_path_change(&file).await?;
1024        let second_conflict = monitor
1025            .detect_conflict(&file, Some("agent\n".to_string()), None)
1026            .await?
1027            .expect("expected renewed conflict");
1028        assert!(second_conflict.emit_hitl_notification);
1029        Ok(())
1030    }
1031
1032    #[tokio::test]
1033    async fn stale_path_queries_expose_external_change_without_clearing_state() -> Result<()> {
1034        let temp = TempDir::new()?;
1035        let file = temp.path().join("sample.txt");
1036        std::fs::write(&file, "before\n")?;
1037        let monitor = EditedFileMonitor::new();
1038
1039        monitor.track_read(&file).await?;
1040        let tracked_paths = monitor.tracked_paths();
1041        assert_eq!(tracked_paths.len(), 1);
1042        let tracked_path = tracked_paths[0].clone();
1043        assert!(monitor.stale_tracked_paths().is_empty());
1044
1045        std::fs::write(&file, "external\n")?;
1046        monitor.debug_process_path_change(&file).await?;
1047
1048        let freshness = monitor.tracked_path_freshness();
1049        assert_eq!(freshness.len(), 1);
1050        assert_eq!(freshness[0].path, tracked_path);
1051        assert!(freshness[0].is_stale);
1052        assert!(freshness[0].fingerprint.is_some());
1053        assert_eq!(monitor.stale_tracked_paths(), vec![freshness[0].path.clone()]);
1054        assert_eq!(monitor.tracked_path_fingerprint(&freshness[0].path), freshness[0].fingerprint);
1055        Ok(())
1056    }
1057
1058    #[tokio::test]
1059    async fn override_requires_matching_approved_snapshot() -> Result<()> {
1060        let temp = TempDir::new()?;
1061        let file = temp.path().join("sample.txt");
1062        std::fs::write(&file, "before\n")?;
1063        let monitor = EditedFileMonitor::new();
1064
1065        monitor.track_read(&file).await?;
1066        std::fs::write(&file, "external one\n")?;
1067        let approved_snapshot = snapshot_path_async(file.clone()).await?;
1068
1069        std::fs::write(&file, "external two\n")?;
1070
1071        let conflict = monitor
1072            .detect_conflict(&file, Some("agent\n".to_string()), Some(approved_snapshot))
1073            .await?;
1074        assert!(conflict.is_some());
1075        Ok(())
1076    }
1077
1078    #[test]
1079    fn normalizes_missing_event_paths_via_canonical_parent() -> Result<()> {
1080        let temp = TempDir::new()?;
1081        let missing = temp.path().join("missing.txt");
1082        let canonical_parent = canonicalize(temp.path())?;
1083
1084        assert_eq!(normalize_event_path(&missing), canonical_parent.join("missing.txt"));
1085        Ok(())
1086    }
1087}