scripting_tool.rs

  1use anyhow::anyhow;
  2use assistant_tool::{Tool, ToolRegistry};
  3use futures::{
  4    channel::{mpsc, oneshot},
  5    SinkExt, StreamExt as _,
  6};
  7use gpui::{App, AppContext as _, AsyncApp, Task, WeakEntity, Window};
  8use mlua::{Function, Lua, MultiValue, Result, UserData, UserDataMethods};
  9use parking_lot::Mutex;
 10use project::{search::SearchQuery, ProjectPath, WorktreeId};
 11use schemars::JsonSchema;
 12use serde::Deserialize;
 13use std::{
 14    cell::RefCell,
 15    collections::{HashMap, HashSet},
 16    path::{Path, PathBuf},
 17    sync::Arc,
 18};
 19use util::paths::PathMatcher;
 20use workspace::Workspace;
 21
 22pub fn init(cx: &App) {
 23    let registry = ToolRegistry::global(cx);
 24    registry.register_tool(ScriptingTool);
 25}
 26
 27#[derive(Debug, Deserialize, JsonSchema)]
 28struct ScriptingToolInput {
 29    lua_script: String,
 30}
 31
 32struct ScriptingTool;
 33
 34impl Tool for ScriptingTool {
 35    fn name(&self) -> String {
 36        "lua-interpreter".into()
 37    }
 38
 39    fn description(&self) -> String {
 40        r#"You can write a Lua script and I'll run it on my code base and tell you what its output was,
 41including both stdout as well as the git diff of changes it made to the filesystem. That way,
 42you can get more information about the code base, or make changes to the code base directly.
 43The lua script will have access to `io` and it will run with the current working directory being in
 44the root of the code base, so you can use it to explore, search, make changes, etc. You can also have
 45the script print things, and I'll tell you what the output was. Note that `io` only has `open`, and
 46then the file it returns only has the methods read, write, and close - it doesn't have popen or
 47anything else. Also, I'm going to be putting this Lua script into JSON, so please don't use Lua's
 48double quote syntax for string literals - use one of Lua's other syntaxes for string literals, so I
 49don't have to escape the double quotes. There will be a global called `search` which accepts a regex
 50(it's implemented using Rust's regex crate, so use that regex syntax) and runs that regex on the contents
 51of every file in the code base (aside from gitignored files), then returns an array of tables with two
 52fields: "path" (the path to the file that had the matches) and "matches" (an array of strings, with each
 53string being a match that was found within the file)."#.into()
 54    }
 55
 56    fn input_schema(&self) -> serde_json::Value {
 57        let schema = schemars::schema_for!(ScriptingToolInput);
 58        serde_json::to_value(&schema).unwrap()
 59    }
 60
 61    fn run(
 62        self: Arc<Self>,
 63        input: serde_json::Value,
 64        workspace: WeakEntity<Workspace>,
 65        _window: &mut Window,
 66        cx: &mut App,
 67    ) -> Task<anyhow::Result<String>> {
 68        let worktree_root_dir_and_id = workspace.update(cx, |workspace, cx| {
 69            let first_worktree = workspace
 70                .visible_worktrees(cx)
 71                .next()
 72                .ok_or_else(|| anyhow!("no worktrees"))?;
 73            let worktree_id = first_worktree.read(cx).id();
 74            let root_dir = workspace
 75                .absolute_path_of_worktree(worktree_id, cx)
 76                .ok_or_else(|| anyhow!("no worktree root"))?;
 77            Ok((root_dir, worktree_id))
 78        });
 79        let (root_dir, worktree_id) = match worktree_root_dir_and_id {
 80            Ok(Ok(worktree_root_dir_and_id)) => worktree_root_dir_and_id,
 81            Ok(Err(err)) => return Task::ready(Err(err)),
 82            Err(err) => return Task::ready(Err(err)),
 83        };
 84        let input = match serde_json::from_value::<ScriptingToolInput>(input) {
 85            Err(err) => return Task::ready(Err(err.into())),
 86            Ok(input) => input,
 87        };
 88
 89        let (foreground_tx, mut foreground_rx) = mpsc::channel::<ForegroundFn>(1);
 90
 91        cx.spawn(move |cx| async move {
 92            while let Some(request) = foreground_rx.next().await {
 93                request.0(cx.clone());
 94            }
 95        })
 96        .detach();
 97
 98        let lua_script = input.lua_script;
 99        cx.background_spawn(async move {
100            let fs_changes = HashMap::new();
101            let output = run_sandboxed_lua(
102                &lua_script,
103                fs_changes,
104                root_dir,
105                worktree_id,
106                workspace,
107                foreground_tx,
108            )
109            .await
110            .map_err(|err| anyhow!(format!("{err}")))?;
111            let output = output.printed_lines.join("\n");
112
113            Ok(format!("The script output the following:\n{output}"))
114        })
115    }
116}
117
118struct ForegroundFn(Box<dyn FnOnce(AsyncApp) + Send>);
119
120async fn run_foreground_fn<R: Send + 'static>(
121    description: &str,
122    foreground_tx: &mut mpsc::Sender<ForegroundFn>,
123    function: Box<dyn FnOnce(AsyncApp) -> anyhow::Result<R> + Send>,
124) -> Result<R> {
125    let (response_tx, response_rx) = oneshot::channel();
126    let send_result = foreground_tx
127        .send(ForegroundFn(Box::new(move |cx| {
128            response_tx.send(function(cx)).ok();
129        })))
130        .await;
131    match send_result {
132        Ok(()) => (),
133        Err(err) => {
134            return Err(mlua::Error::runtime(format!(
135                "Internal error while enqueuing work for {description}: {err}"
136            )))
137        }
138    }
139    match response_rx.await {
140        Ok(Ok(result)) => Ok(result),
141        Ok(Err(err)) => Err(mlua::Error::runtime(format!(
142            "Error while {description}: {err}"
143        ))),
144        Err(oneshot::Canceled) => Err(mlua::Error::runtime(format!(
145            "Internal error: response oneshot was canceled while {description}."
146        ))),
147    }
148}
149
150const SANDBOX_PREAMBLE: &str = include_str!("sandbox_preamble.lua");
151
152struct FileContent(RefCell<Vec<u8>>);
153
154impl UserData for FileContent {
155    fn add_methods<M: UserDataMethods<Self>>(_methods: &mut M) {
156        // FileContent doesn't have any methods so far.
157    }
158}
159
160/// Sandboxed print() function in Lua.
161fn print(lua: &Lua, printed_lines: Arc<Mutex<Vec<String>>>) -> Result<Function> {
162    lua.create_function(move |_, args: MultiValue| {
163        let mut string = String::new();
164
165        for arg in args.into_iter() {
166            // Lua's `print()` prints tab characters between each argument.
167            if !string.is_empty() {
168                string.push('\t');
169            }
170
171            // If the argument's to_string() fails, have the whole function call fail.
172            string.push_str(arg.to_string()?.as_str())
173        }
174
175        printed_lines.lock().push(string);
176
177        Ok(())
178    })
179}
180
181fn search(
182    lua: &Lua,
183    _fs_changes: Arc<Mutex<HashMap<PathBuf, Vec<u8>>>>,
184    root_dir: PathBuf,
185    worktree_id: WorktreeId,
186    workspace: WeakEntity<Workspace>,
187    foreground_tx: mpsc::Sender<ForegroundFn>,
188) -> Result<Function> {
189    lua.create_async_function(move |lua, regex: String| {
190        let root_dir = root_dir.clone();
191        let workspace = workspace.clone();
192        let mut foreground_tx = foreground_tx.clone();
193        async move {
194            use mlua::Table;
195            use regex::Regex;
196            use std::fs;
197
198            // TODO: Allow specification of these options.
199            let search_query = SearchQuery::regex(
200                &regex,
201                false,
202                false,
203                false,
204                PathMatcher::default(),
205                PathMatcher::default(),
206                None,
207            );
208            let search_query = match search_query {
209                Ok(query) => query,
210                Err(e) => return Err(mlua::Error::runtime(format!("Invalid search query: {}", e))),
211            };
212
213            // TODO: Should use `search_query.regex`. The tool description should also be updated,
214            // as it specifies standard regex.
215            let search_regex = match Regex::new(&regex) {
216                Ok(re) => re,
217                Err(e) => return Err(mlua::Error::runtime(format!("Invalid regex: {}", e))),
218            };
219
220            let project_path_rx =
221                find_search_candidates(search_query, workspace, &mut foreground_tx).await?;
222
223            let mut search_results: Vec<Result<Table>> = Vec::new();
224            while let Ok(project_path) = project_path_rx.recv().await {
225                if project_path.worktree_id != worktree_id {
226                    continue;
227                }
228
229                let path = root_dir.join(project_path.path);
230
231                // Skip files larger than 1MB
232                if let Ok(metadata) = fs::metadata(&path) {
233                    if metadata.len() > 1_000_000 {
234                        continue;
235                    }
236                }
237
238                // Attempt to read the file as text
239                if let Ok(content) = fs::read_to_string(&path) {
240                    let mut matches = Vec::new();
241
242                    // Find all regex matches in the content
243                    for capture in search_regex.find_iter(&content) {
244                        matches.push(capture.as_str().to_string());
245                    }
246
247                    // If we found matches, create a result entry
248                    if !matches.is_empty() {
249                        let result_entry = lua.create_table()?;
250                        result_entry.set("path", path.to_string_lossy().to_string())?;
251
252                        let matches_table = lua.create_table()?;
253                        for (i, m) in matches.iter().enumerate() {
254                            matches_table.set(i + 1, m.clone())?;
255                        }
256                        result_entry.set("matches", matches_table)?;
257
258                        search_results.push(Ok(result_entry));
259                    }
260                }
261            }
262
263            // Create a table to hold our results
264            let results_table = lua.create_table()?;
265            for (i, result) in search_results.into_iter().enumerate() {
266                match result {
267                    Ok(entry) => results_table.set(i + 1, entry)?,
268                    Err(e) => return Err(e),
269                }
270            }
271
272            Ok(results_table)
273        }
274    })
275}
276
277async fn find_search_candidates(
278    search_query: SearchQuery,
279    workspace: WeakEntity<Workspace>,
280    foreground_tx: &mut mpsc::Sender<ForegroundFn>,
281) -> Result<smol::channel::Receiver<ProjectPath>> {
282    run_foreground_fn(
283        "finding search file candidates",
284        foreground_tx,
285        Box::new(move |mut cx| {
286            workspace.update(&mut cx, move |workspace, cx| {
287                workspace.project().update(cx, |project, cx| {
288                    project.worktree_store().update(cx, |worktree_store, cx| {
289                        // TODO: Better limit? For now this is the same as
290                        // MAX_SEARCH_RESULT_FILES.
291                        let limit = 5000;
292                        // TODO: Providing non-empty open_entries can make this a bit more
293                        // efficient as it can skip checking that these paths are textual.
294                        let open_entries = HashSet::default();
295                        // TODO: This is searching all worktrees, but should only search the
296                        // current worktree
297                        worktree_store.find_search_candidates(
298                            search_query,
299                            limit,
300                            open_entries,
301                            project.fs().clone(),
302                            cx,
303                        )
304                    })
305                })
306            })
307        }),
308    )
309    .await
310}
311
312/// Sandboxed io.open() function in Lua.
313fn io_open(
314    lua: &Lua,
315    fs_changes: Arc<Mutex<HashMap<PathBuf, Vec<u8>>>>,
316    root_dir: PathBuf,
317) -> Result<Function> {
318    lua.create_function(move |lua, (path_str, mode): (String, Option<String>)| {
319        let mode = mode.unwrap_or_else(|| "r".to_string());
320
321        // Parse the mode string to determine read/write permissions
322        let read_perm = mode.contains('r');
323        let write_perm = mode.contains('w') || mode.contains('a') || mode.contains('+');
324        let append = mode.contains('a');
325        let truncate = mode.contains('w');
326
327        // This will be the Lua value returned from the `open` function.
328        let file = lua.create_table()?;
329
330        // Store file metadata in the file
331        file.set("__path", path_str.clone())?;
332        file.set("__mode", mode.clone())?;
333        file.set("__read_perm", read_perm)?;
334        file.set("__write_perm", write_perm)?;
335
336        // Sandbox the path; it must be within root_dir
337        let path: PathBuf = {
338            let rust_path = Path::new(&path_str);
339
340            // Get absolute path
341            if rust_path.is_absolute() {
342                // Check if path starts with root_dir prefix without resolving symlinks
343                if !rust_path.starts_with(&root_dir) {
344                    return Ok((
345                        None,
346                        format!(
347                            "Error: Absolute path {} is outside the current working directory",
348                            path_str
349                        ),
350                    ));
351                }
352                rust_path.to_path_buf()
353            } else {
354                // Make relative path absolute relative to cwd
355                root_dir.join(rust_path)
356            }
357        };
358
359        // close method
360        let close_fn = {
361            let fs_changes = fs_changes.clone();
362            lua.create_function(move |_lua, file_userdata: mlua::Table| {
363                let write_perm = file_userdata.get::<bool>("__write_perm")?;
364                let path = file_userdata.get::<String>("__path")?;
365
366                if write_perm {
367                    // When closing a writable file, record the content
368                    let content = file_userdata.get::<mlua::AnyUserData>("__content")?;
369                    let content_ref = content.borrow::<FileContent>()?;
370                    let content_vec = content_ref.0.borrow();
371
372                    // Don't actually write to disk; instead, just update fs_changes.
373                    let path_buf = PathBuf::from(&path);
374                    fs_changes
375                        .lock()
376                        .insert(path_buf.clone(), content_vec.clone());
377                }
378
379                Ok(true)
380            })?
381        };
382        file.set("close", close_fn)?;
383
384        // If it's a directory, give it a custom read() and return early.
385        if path.is_dir() {
386            // TODO handle the case where we changed it in the in-memory fs
387
388            // Create a special directory handle
389            file.set("__is_directory", true)?;
390
391            // Store directory entries
392            let entries = match std::fs::read_dir(&path) {
393                Ok(entries) => {
394                    let mut entry_names = Vec::new();
395                    for entry in entries.flatten() {
396                        entry_names.push(entry.file_name().to_string_lossy().into_owned());
397                    }
398                    entry_names
399                }
400                Err(e) => return Ok((None, format!("Error reading directory: {}", e))),
401            };
402
403            // Save the list of entries
404            file.set("__dir_entries", entries)?;
405            file.set("__dir_position", 0usize)?;
406
407            // Create a directory-specific read function
408            let read_fn = lua.create_function(|_lua, file_userdata: mlua::Table| {
409                let position = file_userdata.get::<usize>("__dir_position")?;
410                let entries = file_userdata.get::<Vec<String>>("__dir_entries")?;
411
412                if position >= entries.len() {
413                    return Ok(None); // No more entries
414                }
415
416                let entry = entries[position].clone();
417                file_userdata.set("__dir_position", position + 1)?;
418
419                Ok(Some(entry))
420            })?;
421            file.set("read", read_fn)?;
422
423            // If we got this far, the directory was opened successfully
424            return Ok((Some(file), String::new()));
425        }
426
427        let fs_changes_map = fs_changes.lock();
428
429        let is_in_changes = fs_changes_map.contains_key(&path);
430        let file_exists = is_in_changes || path.exists();
431        let mut file_content = Vec::new();
432
433        if file_exists && !truncate {
434            if is_in_changes {
435                file_content = fs_changes_map.get(&path).unwrap().clone();
436            } else {
437                // Try to read existing content if file exists and we're not truncating
438                match std::fs::read(&path) {
439                    Ok(content) => file_content = content,
440                    Err(e) => return Ok((None, format!("Error reading file: {}", e))),
441                }
442            }
443        }
444
445        drop(fs_changes_map); // Unlock the fs_changes mutex.
446
447        // If in append mode, position should be at the end
448        let position = if append && file_exists {
449            file_content.len()
450        } else {
451            0
452        };
453        file.set("__position", position)?;
454        file.set(
455            "__content",
456            lua.create_userdata(FileContent(RefCell::new(file_content)))?,
457        )?;
458
459        // Create file methods
460
461        // read method
462        let read_fn = {
463            lua.create_function(
464                |_lua, (file_userdata, format): (mlua::Table, Option<mlua::Value>)| {
465                    let read_perm = file_userdata.get::<bool>("__read_perm")?;
466                    if !read_perm {
467                        return Err(mlua::Error::runtime("File not open for reading"));
468                    }
469
470                    let content = file_userdata.get::<mlua::AnyUserData>("__content")?;
471                    let mut position = file_userdata.get::<usize>("__position")?;
472                    let content_ref = content.borrow::<FileContent>()?;
473                    let content_vec = content_ref.0.borrow();
474
475                    if position >= content_vec.len() {
476                        return Ok(None); // EOF
477                    }
478
479                    match format {
480                        Some(mlua::Value::String(s)) => {
481                            let lossy_string = s.to_string_lossy();
482                            let format_str: &str = lossy_string.as_ref();
483
484                            // Only consider the first 2 bytes, since it's common to pass e.g. "*all"  instead of "*a"
485                            match &format_str[0..2] {
486                                "*a" => {
487                                    // Read entire file from current position
488                                    let result = String::from_utf8_lossy(&content_vec[position..])
489                                        .to_string();
490                                    position = content_vec.len();
491                                    file_userdata.set("__position", position)?;
492                                    Ok(Some(result))
493                                }
494                                "*l" => {
495                                    // Read next line
496                                    let mut line = Vec::new();
497                                    let mut found_newline = false;
498
499                                    while position < content_vec.len() {
500                                        let byte = content_vec[position];
501                                        position += 1;
502
503                                        if byte == b'\n' {
504                                            found_newline = true;
505                                            break;
506                                        }
507
508                                        // Skip \r in \r\n sequence but add it if it's alone
509                                        if byte == b'\r' {
510                                            if position < content_vec.len()
511                                                && content_vec[position] == b'\n'
512                                            {
513                                                position += 1;
514                                                found_newline = true;
515                                                break;
516                                            }
517                                        }
518
519                                        line.push(byte);
520                                    }
521
522                                    file_userdata.set("__position", position)?;
523
524                                    if !found_newline
525                                        && line.is_empty()
526                                        && position >= content_vec.len()
527                                    {
528                                        return Ok(None); // EOF
529                                    }
530
531                                    let result = String::from_utf8_lossy(&line).to_string();
532                                    Ok(Some(result))
533                                }
534                                "*n" => {
535                                    // Try to parse as a number (number of bytes to read)
536                                    match format_str.parse::<usize>() {
537                                        Ok(n) => {
538                                            let end =
539                                                std::cmp::min(position + n, content_vec.len());
540                                            let bytes = &content_vec[position..end];
541                                            let result = String::from_utf8_lossy(bytes).to_string();
542                                            position = end;
543                                            file_userdata.set("__position", position)?;
544                                            Ok(Some(result))
545                                        }
546                                        Err(_) => Err(mlua::Error::runtime(format!(
547                                            "Invalid format: {}",
548                                            format_str
549                                        ))),
550                                    }
551                                }
552                                "*L" => {
553                                    // Read next line keeping the end of line
554                                    let mut line = Vec::new();
555
556                                    while position < content_vec.len() {
557                                        let byte = content_vec[position];
558                                        position += 1;
559
560                                        line.push(byte);
561
562                                        if byte == b'\n' {
563                                            break;
564                                        }
565
566                                        // If we encounter a \r, add it and check if the next is \n
567                                        if byte == b'\r'
568                                            && position < content_vec.len()
569                                            && content_vec[position] == b'\n'
570                                        {
571                                            line.push(content_vec[position]);
572                                            position += 1;
573                                            break;
574                                        }
575                                    }
576
577                                    file_userdata.set("__position", position)?;
578
579                                    if line.is_empty() && position >= content_vec.len() {
580                                        return Ok(None); // EOF
581                                    }
582
583                                    let result = String::from_utf8_lossy(&line).to_string();
584                                    Ok(Some(result))
585                                }
586                                _ => Err(mlua::Error::runtime(format!(
587                                    "Unsupported format: {}",
588                                    format_str
589                                ))),
590                            }
591                        }
592                        Some(mlua::Value::Number(n)) => {
593                            // Read n bytes
594                            let n = n as usize;
595                            let end = std::cmp::min(position + n, content_vec.len());
596                            let bytes = &content_vec[position..end];
597                            let result = String::from_utf8_lossy(bytes).to_string();
598                            position = end;
599                            file_userdata.set("__position", position)?;
600                            Ok(Some(result))
601                        }
602                        Some(_) => Err(mlua::Error::runtime("Invalid format")),
603                        None => {
604                            // Default is to read a line
605                            let mut line = Vec::new();
606                            let mut found_newline = false;
607
608                            while position < content_vec.len() {
609                                let byte = content_vec[position];
610                                position += 1;
611
612                                if byte == b'\n' {
613                                    found_newline = true;
614                                    break;
615                                }
616
617                                // Handle \r\n
618                                if byte == b'\r' {
619                                    if position < content_vec.len()
620                                        && content_vec[position] == b'\n'
621                                    {
622                                        position += 1;
623                                        found_newline = true;
624                                        break;
625                                    }
626                                }
627
628                                line.push(byte);
629                            }
630
631                            file_userdata.set("__position", position)?;
632
633                            if !found_newline && line.is_empty() && position >= content_vec.len() {
634                                return Ok(None); // EOF
635                            }
636
637                            let result = String::from_utf8_lossy(&line).to_string();
638                            Ok(Some(result))
639                        }
640                    }
641                },
642            )?
643        };
644        file.set("read", read_fn)?;
645
646        // write method
647        let write_fn = {
648            let fs_changes = fs_changes.clone();
649
650            lua.create_function(move |_lua, (file_userdata, text): (mlua::Table, String)| {
651                let write_perm = file_userdata.get::<bool>("__write_perm")?;
652                if !write_perm {
653                    return Err(mlua::Error::runtime("File not open for writing"));
654                }
655
656                let content = file_userdata.get::<mlua::AnyUserData>("__content")?;
657                let position = file_userdata.get::<usize>("__position")?;
658                let content_ref = content.borrow::<FileContent>()?;
659                let mut content_vec = content_ref.0.borrow_mut();
660
661                let bytes = text.as_bytes();
662
663                // Ensure the vector has enough capacity
664                if position + bytes.len() > content_vec.len() {
665                    content_vec.resize(position + bytes.len(), 0);
666                }
667
668                // Write the bytes
669                for (i, &byte) in bytes.iter().enumerate() {
670                    content_vec[position + i] = byte;
671                }
672
673                // Update position
674                let new_position = position + bytes.len();
675                file_userdata.set("__position", new_position)?;
676
677                // Update fs_changes
678                let path = file_userdata.get::<String>("__path")?;
679                let path_buf = PathBuf::from(path);
680                fs_changes.lock().insert(path_buf, content_vec.clone());
681
682                Ok(true)
683            })?
684        };
685        file.set("write", write_fn)?;
686
687        // If we got this far, the file was opened successfully
688        Ok((Some(file), String::new()))
689    })
690}
691
692/// Runs a Lua script in a sandboxed environment and returns the printed lines
693async fn run_sandboxed_lua(
694    script: &str,
695    fs_changes: HashMap<PathBuf, Vec<u8>>,
696    root_dir: PathBuf,
697    worktree_id: WorktreeId,
698    workspace: WeakEntity<Workspace>,
699    foreground_tx: mpsc::Sender<ForegroundFn>,
700) -> Result<ScriptOutput> {
701    let lua = Lua::new();
702    lua.set_memory_limit(2 * 1024 * 1024 * 1024)?; // 2 GB
703    let globals = lua.globals();
704
705    // Track the lines the Lua script prints out.
706    let printed_lines = Arc::new(Mutex::new(Vec::new()));
707    let fs = Arc::new(Mutex::new(fs_changes));
708
709    globals.set("sb_print", print(&lua, printed_lines.clone())?)?;
710    globals.set(
711        "search",
712        search(
713            &lua,
714            fs.clone(),
715            root_dir.clone(),
716            worktree_id,
717            workspace,
718            foreground_tx,
719        )?,
720    )?;
721    globals.set("sb_io_open", io_open(&lua, fs.clone(), root_dir)?)?;
722    globals.set("user_script", script)?;
723
724    lua.load(SANDBOX_PREAMBLE).exec_async().await?;
725
726    drop(lua); // Decrements the Arcs so that try_unwrap works.
727
728    Ok(ScriptOutput {
729        printed_lines: Arc::try_unwrap(printed_lines)
730            .expect("There are still other references to printed_lines")
731            .into_inner(),
732        fs_changes: Arc::try_unwrap(fs)
733            .expect("There are still other references to fs_changes")
734            .into_inner(),
735    })
736}
737
738pub struct ScriptOutput {
739    printed_lines: Vec<String>,
740    #[allow(dead_code)]
741    fs_changes: HashMap<PathBuf, Vec<u8>>,
742}
743
744#[allow(dead_code)]
745impl ScriptOutput {
746    fn fs_diff(&self) -> HashMap<PathBuf, String> {
747        let mut diff_map = HashMap::new();
748        for (path, content) in &self.fs_changes {
749            let diff = if path.exists() {
750                // Read the current file content
751                match std::fs::read(path) {
752                    Ok(current_content) => {
753                        // Convert both to strings for diffing
754                        let new_content = String::from_utf8_lossy(content).to_string();
755                        let old_content = String::from_utf8_lossy(&current_content).to_string();
756
757                        // Generate a git-style diff
758                        let new_lines: Vec<&str> = new_content.lines().collect();
759                        let old_lines: Vec<&str> = old_content.lines().collect();
760
761                        let path_str = path.to_string_lossy();
762                        let mut diff = format!("diff --git a/{} b/{}\n", path_str, path_str);
763                        diff.push_str(&format!("--- a/{}\n", path_str));
764                        diff.push_str(&format!("+++ b/{}\n", path_str));
765
766                        // Very basic diff algorithm - this is simplified
767                        let mut i = 0;
768                        let mut j = 0;
769
770                        while i < old_lines.len() || j < new_lines.len() {
771                            if i < old_lines.len()
772                                && j < new_lines.len()
773                                && old_lines[i] == new_lines[j]
774                            {
775                                i += 1;
776                                j += 1;
777                                continue;
778                            }
779
780                            // Find next matching line
781                            let mut next_i = i;
782                            let mut next_j = j;
783                            let mut found = false;
784
785                            // Look ahead for matches
786                            for look_i in i..std::cmp::min(i + 10, old_lines.len()) {
787                                for look_j in j..std::cmp::min(j + 10, new_lines.len()) {
788                                    if old_lines[look_i] == new_lines[look_j] {
789                                        next_i = look_i;
790                                        next_j = look_j;
791                                        found = true;
792                                        break;
793                                    }
794                                }
795                                if found {
796                                    break;
797                                }
798                            }
799
800                            // Output the hunk header
801                            diff.push_str(&format!(
802                                "@@ -{},{} +{},{} @@\n",
803                                i + 1,
804                                if found {
805                                    next_i - i
806                                } else {
807                                    old_lines.len() - i
808                                },
809                                j + 1,
810                                if found {
811                                    next_j - j
812                                } else {
813                                    new_lines.len() - j
814                                }
815                            ));
816
817                            // Output removed lines
818                            for k in i..next_i {
819                                diff.push_str(&format!("-{}\n", old_lines[k]));
820                            }
821
822                            // Output added lines
823                            for k in j..next_j {
824                                diff.push_str(&format!("+{}\n", new_lines[k]));
825                            }
826
827                            i = next_i;
828                            j = next_j;
829
830                            if found {
831                                i += 1;
832                                j += 1;
833                            } else {
834                                break;
835                            }
836                        }
837
838                        diff
839                    }
840                    Err(_) => format!("Error reading current file: {}", path.display()),
841                }
842            } else {
843                // New file
844                let content_str = String::from_utf8_lossy(content).to_string();
845                let path_str = path.to_string_lossy();
846                let mut diff = format!("diff --git a/{} b/{}\n", path_str, path_str);
847                diff.push_str("new file mode 100644\n");
848                diff.push_str("--- /dev/null\n");
849                diff.push_str(&format!("+++ b/{}\n", path_str));
850
851                let lines: Vec<&str> = content_str.lines().collect();
852                diff.push_str(&format!("@@ -0,0 +1,{} @@\n", lines.len()));
853
854                for line in lines {
855                    diff.push_str(&format!("+{}\n", line));
856                }
857
858                diff
859            };
860
861            diff_map.insert(path.clone(), diff);
862        }
863
864        diff_map
865    }
866
867    fn diff_to_string(&self) -> String {
868        let mut answer = String::new();
869        let diff_map = self.fs_diff();
870
871        if diff_map.is_empty() {
872            return "No changes to files".to_string();
873        }
874
875        // Sort the paths for consistent output
876        let mut paths: Vec<&PathBuf> = diff_map.keys().collect();
877        paths.sort();
878
879        for path in paths {
880            if !answer.is_empty() {
881                answer.push_str("\n");
882            }
883            answer.push_str(&diff_map[path]);
884        }
885
886        answer
887    }
888}