Skip to main content

winx_code_agent/tools/
undo_edit.rs

1//! Implementation of the `UndoEdit` tool.
2//!
3//! Reverts a file to the content it had before the last `FileWriteOrEdit` /
4//! `MultiFileEdit` in this session, using the in-memory checkpoint those tools
5//! record (see `bash_state::EditCheckpoint`). Per-file LIFO: repeated undos on
6//! one file walk its edits back. A brand-new file's creation has no prior content
7//! and is not undoable.
8
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use tokio::sync::Mutex;
13use tracing::instrument;
14
15use crate::errors::{Result, WinxError};
16use crate::state::bash_state::BashState;
17use crate::tools::file_write_or_edit::{
18    ensure_parent_dirs, hash_content, write_no_follow_if_unchanged,
19};
20use crate::types::{normalize_thread_id, UndoEdit};
21use crate::utils::path::{expand_user, validate_path_in_workspace};
22
23#[instrument(level = "info", skip(bash_state_arc, undo))]
24pub async fn handle_tool_call(
25    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
26    undo: UndoEdit,
27) -> Result<String> {
28    let mut bash_state_guard = bash_state_arc.lock().await;
29    let bash_state = bash_state_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
30
31    let thread_id = normalize_thread_id(&undo.thread_id);
32    if thread_id != bash_state.current_thread_id {
33        return Err(WinxError::ThreadIdMismatch(thread_id));
34    }
35
36    // Resolve + workspace-confine the path exactly like the edit tools, so the key
37    // matches the stored checkpoint and the write stays inside the workspace.
38    let expanded = expand_user(&undo.file_path);
39    let path = if Path::new(&expanded).is_absolute() {
40        PathBuf::from(&expanded)
41    } else {
42        bash_state.cwd.join(&expanded)
43    };
44    let path = validate_path_in_workspace(&path, &bash_state.workspace_root)
45        .map_err(|e| WinxError::PathSecurityError { path: path.clone(), message: e.to_string() })?;
46    let file_path_str = path.to_string_lossy().to_string();
47
48    // Refuse if the file changed since the edit we'd undo. The whitelist holds the
49    // hash of the content winx last wrote; if the disk no longer matches, an undo
50    // would silently discard those newer (external) changes.
51    let on_disk = std::fs::read_to_string(&path).ok();
52    let wrote_hash =
53        bash_state.whitelist_for_overwrite.get(&file_path_str).map(|w| w.file_hash.clone());
54    let unchanged = matches!((&on_disk, &wrote_hash), (Some(c), Some(h)) if &hash_content(c) == h);
55    if !unchanged {
56        return Err(WinxError::FileChangedAfterEdit {
57            path,
58            message: format!(
59                "{file_path_str} changed since its last winx edit (or was deleted), so UndoEdit \
60                 was refused to avoid discarding those changes. Re-read the file and edit it \
61                 manually."
62            ),
63        });
64    }
65
66    let Some(checkpoint) = bash_state.latest_edit_checkpoint_for(&file_path_str) else {
67        return Err(WinxError::UndoCheckpointNotFound { path });
68    };
69
70    // Restore the prior content atomically, then roll the whitelist back so a
71    // later edit's hash gate matches the reverted content (or drop it, forcing a
72    // re-read, when there was none).
73    ensure_parent_dirs(&path)?;
74    write_no_follow_if_unchanged(&path, checkpoint.prior_content.as_bytes(), on_disk.as_deref())?;
75    let removed = bash_state.pop_edit_checkpoint_for(&file_path_str);
76    debug_assert!(removed.is_some(), "checkpoint disappeared while the session lock was held");
77    match checkpoint.prior_whitelist {
78        Some(whitelist) => {
79            bash_state.set_whitelist_entry(&file_path_str, whitelist);
80        }
81        None => {
82            bash_state.remove_whitelist_entry(&file_path_str);
83        }
84    }
85
86    let remaining =
87        bash_state.edit_checkpoints.iter().filter(|cp| cp.file_path_str == file_path_str).count();
88    let lines = checkpoint.prior_content.lines().count();
89    Ok(format!(
90        "Reverted {file_path_str} to its content before the last edit ({lines} lines). \
91         {remaining} earlier checkpoint(s) remain for this file."
92    ))
93}