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 ®ex,
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(®ex) {
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(¤t_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}