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