Skip to main content

rig_tools/
move_file.rs

1//! The `move` tool.
2
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use rig_core::completion::ToolAnnotations;
7use rig_core::tool::{
8    FileChange, FileState, FilesChanged, Scope, Tool, ToolContext, ToolExecutionError, Touches,
9    args_schema,
10};
11use schemars::JsonSchema;
12use serde::Deserialize;
13
14use crate::fs::{Previous, io_error};
15use crate::workspace::{Local, Stat, Workspace};
16use crate::{MAX_FILE_BYTES, blocking};
17
18/// Moves or renames a file, creating the new path's directory; a directory
19/// is refused, and so is an existing file at the new path unless the call
20/// says to overwrite it. Publishes the files it changed ([`FilesChanged`]),
21/// by their [resolved](Workspace) paths: the old one deleted, and
22/// the new one created (or, overwritten, changed) with its hunks.
23#[derive(Clone, Debug)]
24pub struct Move {
25    /// Where the file is moved.
26    pub workspace: Arc<dyn Workspace>,
27    /// The directory relative paths are resolved from; `None` is the
28    /// workspace's working directory.
29    pub base: Option<PathBuf>,
30    /// Whether a path that resolves outside `base` is refused, before
31    /// anything is moved.
32    pub confine: bool,
33}
34
35impl Default for Move {
36    /// Moves this machine's files, from this process's working directory.
37    fn default() -> Self {
38        Self {
39            workspace: Arc::new(Local),
40            base: None,
41            confine: false,
42        }
43    }
44}
45
46/// Arguments of [`Move`].
47#[derive(Clone, Debug, PartialEq, Eq, Deserialize, JsonSchema)]
48#[serde(deny_unknown_fields)]
49pub struct MoveArgs {
50    /// The file to move.
51    pub from: String,
52    /// Its new path.
53    pub to: String,
54    /// Replace a file already at `to`.
55    #[serde(default)]
56    pub overwrite: bool,
57}
58
59impl MoveArgs {
60    /// Move `from` to `to`, refusing to replace a file there.
61    pub fn new(from: impl Into<String>, to: impl Into<String>) -> Self {
62        Self {
63            from: from.into(),
64            to: to.into(),
65            overwrite: false,
66        }
67    }
68
69    /// The same move, replacing a file at `to`.
70    pub fn overwrite(mut self) -> Self {
71        self.overwrite = true;
72        self
73    }
74}
75
76impl Tool for Move {
77    const NAME: &'static str = "move";
78    type Args = MoveArgs;
79    type Output = String;
80    type Error = ToolExecutionError;
81
82    fn description(&self) -> String {
83        "Move or rename a file, not with `mv` in `shell`, creating the new path's directory. \
84         Refuses directories, and an existing file at `to` unless `overwrite` is true."
85            .to_owned()
86    }
87
88    fn parameters(&self) -> serde_json::Value {
89        args_schema::<MoveArgs>()
90    }
91
92    fn annotations(&self) -> ToolAnnotations {
93        ToolAnnotations::default()
94            .with_destructive(true)
95            .with_idempotent(false)
96            .with_open_world(false)
97    }
98
99    fn footprint(&self, args: &MoveArgs) -> Touches {
100        let base = self.base.as_deref();
101        Touches::Writes(Scope::Paths(vec![
102            self.workspace.resolve(base, &args.from),
103            self.workspace.resolve(base, &args.to),
104        ]))
105    }
106
107    async fn call(
108        &self,
109        context: &mut ToolContext,
110        args: MoveArgs,
111    ) -> Result<String, ToolExecutionError> {
112        let base = self.base.as_deref();
113        let from = self.workspace.place(base, self.confine, &args.from)?;
114        let to = self.workspace.place(base, self.confine, &args.to)?;
115        let workspace = self.workspace.clone();
116        let overwrite = args.overwrite;
117        let changes = blocking(move || move_file(&*workspace, &from, &to, overwrite)).await?;
118        context.insert_result(FilesChanged(changes))?;
119        Ok(format!("Moved {} to {}.", args.from, args.to))
120    }
121}
122
123/// Moves the file `from` to `to` in `workspace`, and says what that did to
124/// both.
125fn move_file(
126    workspace: &dyn Workspace,
127    from: &Path,
128    to: &Path,
129    overwrite: bool,
130) -> Result<Vec<FileChange>, ToolExecutionError> {
131    let (shown_from, shown_to) = (from.to_string_lossy(), to.to_string_lossy());
132    let size = match workspace
133        .stat(from)
134        .map_err(|error| io_error(&shown_from, error))?
135    {
136        Stat::File { size, .. } => size,
137        Stat::Missing => {
138            return Err(ToolExecutionError::not_found(format!(
139                "{shown_from}: no such file"
140            )));
141        }
142        Stat::Dir | Stat::Other => {
143            return Err(ToolExecutionError::invalid_args(format!(
144                "{shown_from} is not a regular file; `move` moves files only"
145            )));
146        }
147    };
148    if from == to {
149        return Err(ToolExecutionError::invalid_args(format!(
150            "{shown_from} and {shown_to} are the same file"
151        )));
152    }
153    match workspace
154        .stat(to)
155        .map_err(|error| io_error(&shown_to, error))?
156    {
157        Stat::Missing => {}
158        Stat::File { .. } if overwrite => {}
159        Stat::File { .. } => {
160            return Err(ToolExecutionError::invalid_args(format!(
161                "{shown_to} already exists; set `overwrite` to replace it"
162            )));
163        }
164        Stat::Dir | Stat::Other => {
165            return Err(ToolExecutionError::invalid_args(format!(
166                "{shown_to} is not a regular file; give the file's new path"
167            )));
168        }
169    }
170    // The moved bytes, when the file is small enough to diff and hash.
171    let moved = (size <= MAX_FILE_BYTES)
172        .then(|| workspace.read(from, MAX_FILE_BYTES).ok())
173        .flatten()
174        .map(|contents| contents.bytes);
175    let before = workspace.previous(to);
176    workspace
177        .rename(from, to)
178        .map_err(|error| io_error(&shown_from, error))?;
179    // The moved file's content is in the created half's diff, so the
180    // deleted half has none.
181    let bytes = moved.as_deref().unwrap_or_default();
182    let mut created = Previous::change(before, &shown_to, Some(bytes));
183    let read = created.after.take().filter(|_| moved.is_some());
184    let state = read.unwrap_or_else(|| FileState::unread(size));
185    created.after = Some(state.clone());
186    let deleted =
187        FileChange::deleted(shown_from, state.lines.unwrap_or_default()).with_before(state);
188    Ok(vec![deleted, created])
189}
190
191#[cfg(test)]
192mod tests;