| use std::cmp::min; |
| use std::collections::HashMap; |
| use std::path::{Path, PathBuf}; |
|
|
| use console::strip_ansi_codes; |
| use derive_setters::Setters; |
| use forge_config::ForgeConfig; |
| use forge_display::DiffFormat; |
| use forge_domain::{ |
| CodebaseSearchResults, Environment, FSMultiPatch, FSPatch, FSRead, FSRemove, FSSearch, FSUndo, |
| FSWrite, FileOperation, LineNumbers, Metrics, NetFetch, PlanCreate, ToolKind, |
| }; |
| use forge_template::Element; |
|
|
| use crate::truncation::{ |
| Stderr, Stdout, TruncationMode, truncate_fetch_content, truncate_search_output, |
| truncate_shell_output, |
| }; |
| use crate::utils::{compute_hash, format_display_path}; |
| use crate::{ |
| FsRemoveOutput, FsUndoOutput, FsWriteOutput, HttpResponse, PatchOutput, PlanCreateOutput, |
| ReadOutput, ResponseContext, SearchResult, ShellOutput, |
| }; |
|
|
| #[derive(Debug, Default, Setters)] |
| #[setters(into, strip_option)] |
| pub struct TempContentFiles { |
| stdout: Option<PathBuf>, |
| stderr: Option<PathBuf>, |
| } |
|
|
| #[derive(Debug, derive_more::From)] |
| pub enum ToolOperation { |
| FsRead { |
| input: FSRead, |
| output: ReadOutput, |
| }, |
| FsWrite { |
| input: FSWrite, |
| output: FsWriteOutput, |
| }, |
| FsRemove { |
| input: FSRemove, |
| output: FsRemoveOutput, |
| }, |
| FsSearch { |
| input: FSSearch, |
| output: Option<SearchResult>, |
| }, |
| CodebaseSearch { |
| output: CodebaseSearchResults, |
| }, |
| FsPatch { |
| input: FSPatch, |
| output: PatchOutput, |
| }, |
| FsMultiPatch { |
| input: FSMultiPatch, |
| output: PatchOutput, |
| }, |
| FsUndo { |
| input: FSUndo, |
| output: FsUndoOutput, |
| }, |
| NetFetch { |
| input: NetFetch, |
| output: HttpResponse, |
| }, |
| Shell { |
| output: ShellOutput, |
| }, |
| FollowUp { |
| output: Option<String>, |
| }, |
| PlanCreate { |
| input: PlanCreate, |
| output: PlanCreateOutput, |
| }, |
| Skill { |
| output: forge_domain::Skill, |
| }, |
| TodoWrite { |
| before: Vec<forge_domain::Todo>, |
| after: Vec<forge_domain::Todo>, |
| }, |
| TodoRead { |
| output: Vec<forge_domain::Todo>, |
| }, |
| } |
|
|
| |
| pub trait StreamElement { |
| fn stream_name(&self) -> &'static str; |
| fn head_content(&self) -> &str; |
| fn tail_content(&self) -> Option<&str>; |
| fn total_lines(&self) -> usize; |
| fn head_end_line(&self) -> usize; |
| fn tail_start_line(&self) -> Option<usize>; |
| fn tail_end_line(&self) -> Option<usize>; |
| } |
|
|
| impl StreamElement for Stdout { |
| fn stream_name(&self) -> &'static str { |
| "stdout" |
| } |
|
|
| fn head_content(&self) -> &str { |
| &self.head |
| } |
|
|
| fn tail_content(&self) -> Option<&str> { |
| self.tail.as_deref() |
| } |
|
|
| fn total_lines(&self) -> usize { |
| self.total_lines |
| } |
|
|
| fn head_end_line(&self) -> usize { |
| self.head_end_line |
| } |
|
|
| fn tail_start_line(&self) -> Option<usize> { |
| self.tail_start_line |
| } |
|
|
| fn tail_end_line(&self) -> Option<usize> { |
| self.tail_end_line |
| } |
| } |
|
|
| impl StreamElement for Stderr { |
| fn stream_name(&self) -> &'static str { |
| "stderr" |
| } |
|
|
| fn head_content(&self) -> &str { |
| &self.head |
| } |
|
|
| fn tail_content(&self) -> Option<&str> { |
| self.tail.as_deref() |
| } |
|
|
| fn total_lines(&self) -> usize { |
| self.total_lines |
| } |
|
|
| fn head_end_line(&self) -> usize { |
| self.head_end_line |
| } |
|
|
| fn tail_start_line(&self) -> Option<usize> { |
| self.tail_start_line |
| } |
|
|
| fn tail_end_line(&self) -> Option<usize> { |
| self.tail_end_line |
| } |
| } |
|
|
| |
| |
| fn create_stream_element<T: StreamElement>( |
| stream: &T, |
| full_output_path: Option<&Path>, |
| ) -> Option<Element> { |
| if stream.head_content().is_empty() { |
| return None; |
| } |
|
|
| let mut elem = Element::new(stream.stream_name()).attr("total_lines", stream.total_lines()); |
|
|
| elem = if let Some(((tail, tail_start), tail_end)) = stream |
| .tail_content() |
| .zip(stream.tail_start_line()) |
| .zip(stream.tail_end_line()) |
| { |
| elem.append( |
| Element::new("head") |
| .attr("display_lines", format!("1-{}", stream.head_end_line())) |
| .cdata(stream.head_content()), |
| ) |
| .append( |
| Element::new("tail") |
| .attr("display_lines", format!("{tail_start}-{tail_end}")) |
| .cdata(tail), |
| ) |
| } else { |
| elem.cdata(stream.head_content()) |
| }; |
|
|
| if let Some(path) = full_output_path { |
| elem = elem.attr("full_output", path.display()); |
| } |
|
|
| Some(elem) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| fn create_validation_warning(path: &str, errors: &[forge_domain::SyntaxError]) -> Element { |
| Element::new("warning") |
| .append(Element::new("message").text("Syntax validation failed")) |
| .append(Element::new("file").attr("path", path)) |
| .append(Element::new("details").text(format!( |
| "The file was written successfully but contains {} syntax error(s)", |
| errors.len() |
| ))) |
| .append(errors.iter().map(|error| { |
| Element::new("error") |
| .attr("line", error.line.to_string()) |
| .attr("column", error.column.to_string()) |
| .cdata(&error.message) |
| })) |
| .append(Element::new("suggestion").text("Review and fix the syntax issues")) |
| } |
|
|
| impl ToolOperation { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| pub fn into_tool_output( |
| self, |
| tool_kind: ToolKind, |
| content_files: TempContentFiles, |
| env: &Environment, |
| config: &ForgeConfig, |
| metrics: &mut Metrics, |
| ) -> forge_domain::ToolOutput { |
| let tool_name = tool_kind.name(); |
| match self { |
| ToolOperation::FsRead { input, output } => { |
| |
| if let Some(image) = output.content.as_image() { |
| |
| tracing::info!( |
| path = %input.file_path, |
| tool = %tool_name, |
| "Visual content read (image/PDF)" |
| ); |
| *metrics = metrics.clone().insert( |
| input.file_path.clone(), |
| FileOperation::new(tool_kind) |
| .content_hash(Some(output.info.content_hash.clone())), |
| ); |
|
|
| return forge_domain::ToolOutput::image(image.clone()); |
| } |
|
|
| |
| let content = output.content.file_content(); |
| let content = if input.show_line_numbers { |
| content |
| .to_numbered_from(output.info.start_line as usize) |
| .to_string() |
| } else { |
| content.to_string() |
| }; |
| let elm = Element::new("file") |
| .attr("path", &input.file_path) |
| .attr( |
| "display_lines", |
| format!("{}-{}", output.info.start_line, output.info.end_line), |
| ) |
| .attr("total_lines", output.info.total_lines) |
| .cdata(content); |
|
|
| |
| tracing::info!( |
| path = %input.file_path, |
| tool = %tool_name, |
| "File read" |
| ); |
| *metrics = metrics.clone().insert( |
| input.file_path.clone(), |
| FileOperation::new(tool_kind) |
| .content_hash(Some(output.info.content_hash.clone())), |
| ); |
|
|
| forge_domain::ToolOutput::text(elm) |
| } |
| ToolOperation::FsWrite { input, output } => { |
| let diff_result = DiffFormat::format( |
| output.before.as_ref().unwrap_or(&"".to_string()), |
| &input.content, |
| ); |
| let diff = console::strip_ansi_codes(diff_result.diff()).to_string(); |
|
|
| *metrics = metrics.clone().insert( |
| input.file_path.clone(), |
| FileOperation::new(tool_kind) |
| .lines_added(diff_result.lines_added()) |
| .lines_removed(diff_result.lines_removed()) |
| .content_hash(Some(output.content_hash.clone())), |
| ); |
|
|
| let mut elm = if output.before.as_ref().is_some() { |
| Element::new("file_overwritten").append(Element::new("file_diff").cdata(diff)) |
| } else { |
| Element::new("file_created") |
| }; |
|
|
| elm = elm |
| .attr("path", &input.file_path) |
| .attr("total_lines", input.content.lines().count()); |
|
|
| if !output.errors.is_empty() { |
| elm = elm.append(create_validation_warning(&input.file_path, &output.errors)); |
| } |
|
|
| forge_domain::ToolOutput::text(elm) |
| } |
| ToolOperation::FsRemove { input, output } => { |
| |
| let content_hash = None; |
|
|
| *metrics = metrics.clone().insert( |
| input.path.clone(), |
| FileOperation::new(tool_kind) |
| .lines_removed(output.content.lines().count() as u64) |
| .content_hash(content_hash), |
| ); |
|
|
| let display_path = format_display_path(Path::new(&input.path), env.cwd.as_path()); |
| let elem = Element::new("file_removed") |
| .attr("path", display_path) |
| .attr("status", "completed"); |
| forge_domain::ToolOutput::text(elem) |
| } |
|
|
| ToolOperation::FsSearch { input, output } => match output { |
| Some(out) => { |
| let max_lines = min( |
| config.max_search_lines, |
| input.head_limit.unwrap_or(u32::MAX) as usize, |
| ); |
| let offset = input.offset.unwrap_or(0) as usize; |
| let search_dir = Path::new(input.path.as_deref().unwrap_or(".")); |
| let truncated_output = truncate_search_output( |
| &out.matches, |
| offset, |
| max_lines, |
| config.max_search_result_bytes, |
| search_dir, |
| ); |
|
|
| let display_lines = if truncated_output.start < truncated_output.end { |
| |
| format!("{}-{}", truncated_output.start + 1, truncated_output.end) |
| } else { |
| |
| "0-0".to_string() |
| }; |
|
|
| let mut elm = Element::new("search_results") |
| .attr("path", input.path.as_deref().unwrap_or(".")) |
| .attr("max_bytes_allowed", config.max_search_result_bytes) |
| .attr("total_lines", truncated_output.total) |
| .attr("display_lines", display_lines); |
|
|
| elm = elm.attr("pattern", &input.pattern); |
| elm = elm.attr_if_some("glob", input.glob.as_ref()); |
| elm = elm.attr_if_some("file_type", input.file_type.as_ref()); |
|
|
| match truncated_output.strategy { |
| TruncationMode::Byte => { |
| let reason = format!( |
| "Results truncated due to exceeding the {} bytes size limit. Please use a more specific search pattern", |
| config.max_search_result_bytes |
| ); |
| elm = elm.attr("reason", reason); |
| } |
| TruncationMode::Line => { |
| let reason = format!( |
| "Results truncated due to exceeding the {max_lines} lines limit. Please use a more specific search pattern" |
| ); |
| elm = elm.attr("reason", reason); |
| } |
| TruncationMode::Full => {} |
| }; |
| elm = elm.cdata(truncated_output.data.join("\n")); |
|
|
| forge_domain::ToolOutput::text(elm) |
| } |
| None => { |
| let mut elm = Element::new("search_results"); |
| elm = elm.attr_if_some("path", input.path); |
| elm = elm.attr("pattern", &input.pattern); |
| elm = elm.attr_if_some("glob", input.glob); |
| elm = elm.attr_if_some("file_type", input.file_type.as_ref()); |
| forge_domain::ToolOutput::text(elm) |
| } |
| }, |
| ToolOperation::CodebaseSearch { output } => { |
| let total_results: usize = output.queries.iter().map(|q| q.results.len()).sum(); |
| let mut root = Element::new("sem_search_results"); |
|
|
| if output.queries.is_empty() || total_results == 0 { |
| root = root.text("No results found for query. Try refining your search with more specific terms or different keywords.") |
| } else { |
| for query_result in &output.queries { |
| let query_elm = Element::new("query_result") |
| .attr("query", &query_result.query) |
| .attr("use_case", &query_result.use_case) |
| .attr("results", query_result.results.len()); |
|
|
| let mut grouped_by_path: HashMap<&str, Vec<_>> = HashMap::new(); |
|
|
| |
| for data in &query_result.results { |
| if let forge_domain::NodeData::FileChunk(file_chunk) = &data.node { |
| let key = file_chunk.file_path.as_str(); |
| grouped_by_path.entry(key).or_default().push(file_chunk); |
| } |
| } |
|
|
| |
| let mut grouped_chunks: Vec<_> = grouped_by_path.into_iter().collect(); |
| grouped_chunks.sort_by(|a, b| a.0.cmp(b.0)); |
|
|
| let mut result_elm = Vec::new(); |
|
|
| |
| for (path, mut chunks) in grouped_chunks { |
| |
| chunks.sort_by_key(|a| a.start_line); |
|
|
| let mut content_parts = Vec::new(); |
| for chunk in chunks { |
| let numbered = chunk |
| .content |
| .to_numbered_from(chunk.start_line as usize) |
| .to_string(); |
| content_parts.push(numbered); |
| } |
|
|
| let data = content_parts.join("\n...\n"); |
| let element = Element::new("file").attr("path", path).cdata(data); |
| result_elm.push(element); |
| } |
|
|
| root = root.append(query_elm.append(result_elm)); |
| } |
| } |
|
|
| forge_domain::ToolOutput::text(root) |
| } |
| ToolOperation::FsPatch { input, output } => { |
| let diff_result = DiffFormat::format(&output.before, &output.after); |
| let diff = console::strip_ansi_codes(diff_result.diff()).to_string(); |
|
|
| let mut elm = Element::new("file_diff") |
| .attr("path", &input.file_path) |
| .attr("total_lines", output.after.lines().count()) |
| .cdata(diff); |
|
|
| if !output.errors.is_empty() { |
| elm = elm.append(create_validation_warning(&input.file_path, &output.errors)); |
| } |
|
|
| *metrics = metrics.clone().insert( |
| input.file_path.clone(), |
| FileOperation::new(tool_kind) |
| .lines_added(diff_result.lines_added()) |
| .lines_removed(diff_result.lines_removed()) |
| .content_hash(Some(output.content_hash.clone())), |
| ); |
|
|
| forge_domain::ToolOutput::text(elm) |
| } |
| ToolOperation::FsMultiPatch { input, output } => { |
| let diff_result = DiffFormat::format(&output.before, &output.after); |
| let diff = console::strip_ansi_codes(diff_result.diff()).to_string(); |
|
|
| let mut elm = Element::new("file_diff") |
| .attr("path", &input.file_path) |
| .attr("total_lines", output.after.lines().count()) |
| .cdata(diff); |
|
|
| if !output.errors.is_empty() { |
| elm = elm.append(create_validation_warning(&input.file_path, &output.errors)); |
| } |
|
|
| *metrics = metrics.clone().insert( |
| input.file_path.clone(), |
| FileOperation::new(tool_kind) |
| .lines_added(diff_result.lines_added()) |
| .lines_removed(diff_result.lines_removed()) |
| .content_hash(Some(output.content_hash.clone())), |
| ); |
|
|
| forge_domain::ToolOutput::text(elm) |
| } |
| ToolOperation::FsUndo { input, output } => { |
| |
| |
| let diff = DiffFormat::format( |
| output.after_undo.as_deref().unwrap_or(""), |
| output.before_undo.as_deref().unwrap_or(""), |
| ); |
| let content_hash = output.after_undo.as_ref().map(|s| compute_hash(s)); |
|
|
| *metrics = metrics.clone().insert( |
| input.path.clone(), |
| FileOperation::new(tool_kind) |
| .lines_added(diff.lines_added()) |
| .lines_removed(diff.lines_removed()) |
| .content_hash(content_hash), |
| ); |
|
|
| match (&output.before_undo, &output.after_undo) { |
| (None, None) => { |
| let elm = Element::new("file_undo") |
| .attr("path", input.path) |
| .attr("status", "no_changes"); |
| forge_domain::ToolOutput::text(elm) |
| } |
| (None, Some(after)) => { |
| let elm = Element::new("file_undo") |
| .attr("path", input.path) |
| .attr("status", "created") |
| .attr("total_lines", after.lines().count()) |
| .cdata(after); |
| forge_domain::ToolOutput::text(elm) |
| } |
| (Some(before), None) => { |
| let elm = Element::new("file_undo") |
| .attr("path", input.path) |
| .attr("status", "removed") |
| .attr("total_lines", before.lines().count()) |
| .cdata(before); |
| forge_domain::ToolOutput::text(elm) |
| } |
| (Some(before), Some(after)) => { |
| |
| |
| let diff = DiffFormat::format(before, after); |
|
|
| let elm = Element::new("file_undo") |
| .attr("path", input.path) |
| .attr("status", "restored") |
| .cdata(strip_ansi_codes(diff.diff())); |
|
|
| forge_domain::ToolOutput::text(elm) |
| } |
| } |
| } |
| ToolOperation::NetFetch { input, output } => { |
| let content_type = match output.context { |
| ResponseContext::Parsed => "text/markdown".to_string(), |
| ResponseContext::Raw => output.content_type, |
| }; |
| let truncated_content = |
| truncate_fetch_content(&output.content, config.max_fetch_chars); |
| let mut elm = Element::new("http_response") |
| .attr("url", &input.url) |
| .attr("status_code", output.code) |
| .attr("start_char", 0) |
| .attr("end_char", config.max_fetch_chars.min(output.content.len())) |
| .attr("total_chars", output.content.len()) |
| .attr("content_type", content_type); |
|
|
| elm = elm.append(Element::new("body").cdata(truncated_content.content)); |
| if let Some(path) = content_files.stdout { |
| elm = elm.append(Element::new("truncated").text( |
| format!( |
| "Content is truncated to {} chars, remaining content can be read from path: {}", |
| config.max_fetch_chars, path.display()) |
| )); |
| } |
|
|
| forge_domain::ToolOutput::text(elm) |
| } |
| ToolOperation::Shell { output } => { |
| let mut parent_elem = Element::new("shell_output") |
| .attr("command", &output.output.command) |
| .attr("shell", &output.shell); |
|
|
| if let Some(description) = &output.description { |
| parent_elem = parent_elem.attr("description", description); |
| } |
|
|
| if let Some(exit_code) = output.output.exit_code { |
| parent_elem = parent_elem.attr("exit_code", exit_code); |
| } |
|
|
| let truncated_output = truncate_shell_output( |
| &output.output.stdout, |
| &output.output.stderr, |
| config.max_stdout_prefix_lines, |
| config.max_stdout_suffix_lines, |
| config.max_stdout_line_chars, |
| ); |
|
|
| let stdout_elem = create_stream_element( |
| &truncated_output.stdout, |
| content_files.stdout.as_deref(), |
| ); |
|
|
| let stderr_elem = create_stream_element( |
| &truncated_output.stderr, |
| content_files.stderr.as_deref(), |
| ); |
|
|
| parent_elem = parent_elem.append(stdout_elem); |
| parent_elem = parent_elem.append(stderr_elem); |
|
|
| forge_domain::ToolOutput::text(parent_elem) |
| } |
| ToolOperation::FollowUp { output } => match output { |
| None => { |
| let elm = Element::new("interrupted").text("No feedback provided"); |
| forge_domain::ToolOutput::text(elm) |
| } |
| Some(content) => { |
| let elm = Element::new("feedback").text(content); |
| forge_domain::ToolOutput::text(elm) |
| } |
| }, |
| ToolOperation::PlanCreate { input, output } => { |
| let elm = Element::new("plan_created") |
| .attr("path", output.path.display().to_string()) |
| .attr("plan_name", input.plan_name) |
| .attr("version", input.version); |
|
|
| forge_domain::ToolOutput::text(elm) |
| } |
| ToolOperation::Skill { output } => { |
| let mut elm = Element::new("skill_details"); |
|
|
| elm = elm.append({ |
| let mut elm = Element::new("command"); |
| if let Some(path) = output.path { |
| elm = elm.attr("location", path.display().to_string()); |
| } |
|
|
| elm.cdata(output.command) |
| }); |
|
|
| |
| if !output.resources.is_empty() { |
| elm = elm.append(output.resources.iter().map(|resource| { |
| Element::new("resource").text(resource.display().to_string()) |
| })); |
| } |
|
|
| forge_domain::ToolOutput::text(elm) |
| } |
| ToolOperation::TodoWrite { before, after } => { |
| |
| let before_map: std::collections::HashMap<&str, &forge_domain::Todo> = |
| before.iter().map(|t| (t.id.as_str(), t)).collect(); |
|
|
| let mut added = Vec::new(); |
| let mut updated = Vec::new(); |
|
|
| for todo in &after { |
| match before_map.get(todo.id.as_str()) { |
| None => added.push(todo), |
| Some(prev) |
| if prev.status != todo.status || prev.content != todo.content => |
| { |
| updated.push((prev, todo)) |
| } |
| _ => {} |
| } |
| } |
|
|
| let after_ids: std::collections::HashSet<&str> = |
| after.iter().map(|t| t.id.as_str()).collect(); |
| let removed: Vec<_> = before |
| .iter() |
| .filter(|t| !after_ids.contains(t.id.as_str())) |
| .collect(); |
|
|
| let total_changes = added.len() + updated.len() + removed.len(); |
| let mut elm = Element::new("todos_updated").attr("changes", total_changes); |
|
|
| for todo in added { |
| let todo_elm = Element::new("todo") |
| .attr("status", todo.status.to_string()) |
| .attr("change", "added") |
| .text(&todo.content); |
| elm = elm.append(todo_elm); |
| } |
|
|
| for (prev, todo) in updated { |
| let mut todo_elm = Element::new("todo") |
| .attr("status", todo.status.to_string()) |
| .attr("change", "updated"); |
| if prev.status != todo.status { |
| todo_elm = todo_elm |
| .attr("prev_status", prev.status.to_string()) |
| .attr("new_status", todo.status.to_string()); |
| } |
| todo_elm = todo_elm.text(&todo.content); |
| elm = elm.append(todo_elm); |
| } |
|
|
| for todo in removed { |
| let todo_elm = Element::new("todo") |
| .attr("status", todo.status.to_string()) |
| .attr("change", "removed") |
| .text(&todo.content); |
| elm = elm.append(todo_elm); |
| } |
|
|
| forge_domain::ToolOutput::text(elm) |
| } |
| ToolOperation::TodoRead { output } => { |
| let mut elm = Element::new("todos").attr("count", output.len()); |
|
|
| for todo in output { |
| let todo_elm = Element::new("todo") |
| .attr("status", todo.status.to_string()) |
| .text(&todo.content); |
| elm = elm.append(todo_elm); |
| } |
|
|
| forge_domain::ToolOutput::text(elm) |
| } |
| } |
| } |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use std::fmt::Write; |
| use std::path::PathBuf; |
|
|
| use forge_domain::{FSRead, FSReadRange, FileInfo, ToolValue}; |
|
|
| use super::*; |
| use crate::{Content, Match, MatchResult}; |
|
|
| fn fixture_environment() -> Environment { |
| use fake::{Fake, Faker}; |
| let fixture: Environment = Faker.fake(); |
| fixture.cwd(PathBuf::from("/projects/test")) |
| } |
|
|
| fn fixture_config() -> ForgeConfig { |
| let max_bytes: f64 = 250.0 * 1024.0; |
| ForgeConfig { |
| max_search_lines: 25, |
| max_search_result_bytes: max_bytes.ceil() as usize, |
| max_fetch_chars: 55, |
| max_read_lines: 10, |
| max_stdout_prefix_lines: 10, |
| max_stdout_suffix_lines: 10, |
| max_stdout_line_chars: 2000, |
| max_line_chars: 100, |
| max_file_size_bytes: 256 << 10, |
| ..ForgeConfig::default() |
| } |
| } |
|
|
| fn to_value(output: forge_domain::ToolOutput) -> String { |
| let values = output.values; |
| let mut result = String::new(); |
| values.into_iter().for_each(|value| match value { |
| ToolValue::Text(txt) => { |
| writeln!(result, "{}", txt).unwrap(); |
| } |
| ToolValue::Image(image) => { |
| writeln!(result, "Image with mime type: {}", image.mime_type()).unwrap(); |
| } |
| ToolValue::Empty => { |
| writeln!(result, "Empty value").unwrap(); |
| } |
| ToolValue::AI { value, .. } => { |
| writeln!(result, "{}", value).unwrap(); |
| } |
| }); |
|
|
| result |
| } |
|
|
| |
| fn test_syntax_errors(errors: Vec<(u32, u32, &str)>) -> Vec<forge_domain::SyntaxError> { |
| use forge_domain::SyntaxError; |
|
|
| errors |
| .into_iter() |
| .map(|(line, column, message)| SyntaxError { |
| line, |
| column, |
| message: message.to_string(), |
| }) |
| .collect() |
| } |
|
|
| |
| mod sem_search_helpers { |
| use fake::{Fake, Faker}; |
| use forge_domain::{CodebaseQueryResult, CodebaseSearchResults, FileChunk, Node, NodeData}; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| pub fn chunk_node(file_path: &str, content: &str, start_line: u32) -> Node { |
| let line_count = content.lines().count() as u32; |
| let end_line = start_line + line_count.saturating_sub(1); |
| let relevance = 0.9; |
| let node_id: String = Faker.fake(); |
|
|
| Node { |
| node_id: node_id.into(), |
| node: NodeData::FileChunk(FileChunk { |
| file_path: file_path.to_string(), |
| content: content.to_string(), |
| start_line, |
| end_line, |
| }), |
| relevance: Some(relevance), |
| distance: Some(1.0 - relevance), |
| } |
| } |
|
|
| |
| pub fn search_results( |
| query: &str, |
| use_case: &str, |
| nodes: Vec<Node>, |
| ) -> CodebaseSearchResults { |
| CodebaseSearchResults { |
| queries: vec![CodebaseQueryResult { |
| query: query.to_string(), |
| use_case: use_case.to_string(), |
| results: nodes, |
| }], |
| } |
| } |
| } |
|
|
| #[test] |
| fn test_fs_read_basic() { |
| let content = "Hello, world!\nThis is a test file."; |
| let hash = crate::compute_hash(content); |
| let fixture = ToolOperation::FsRead { |
| input: FSRead { |
| file_path: "/home/user/test.txt".to_string(), |
| range: None, |
| show_line_numbers: true, |
| }, |
| output: ReadOutput { |
| content: Content::file(content), |
| info: FileInfo::new(1, 2, 2, hash), |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Read, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_read_basic_special_chars() { |
| let content = "struct Foo<T>{ name: T }"; |
| let hash = crate::compute_hash(content); |
| let fixture = ToolOperation::FsRead { |
| input: FSRead { |
| file_path: "/home/user/test.txt".to_string(), |
| range: None, |
| show_line_numbers: true, |
| }, |
| output: ReadOutput { |
| content: Content::file(content), |
| info: FileInfo::new(1, 1, 1, hash), |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
| let actual = fixture.into_tool_output( |
| ToolKind::Read, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_read_with_explicit_range() { |
| let content = "Line 1\nLine 2\nLine 3"; |
| let hash = crate::compute_hash(content); |
| let fixture = ToolOperation::FsRead { |
| input: FSRead { |
| file_path: "/home/user/test.txt".to_string(), |
| range: Some(FSReadRange { start_line: Some(2), end_line: Some(3) }), |
| show_line_numbers: true, |
| }, |
| output: ReadOutput { |
| content: Content::file(content), |
| info: FileInfo::new(2, 3, 5, hash), |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Read, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_read_with_truncation_path() { |
| let content = "Truncated content"; |
| let hash = crate::compute_hash(content); |
| let fixture = ToolOperation::FsRead { |
| input: FSRead { |
| file_path: "/home/user/large_file.txt".to_string(), |
| range: None, |
| show_line_numbers: true, |
| }, |
| output: ReadOutput { |
| content: Content::file(content), |
| info: FileInfo::new(1, 100, 200, hash), |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
| let truncation_path = |
| TempContentFiles::default().stdout(PathBuf::from("/tmp/truncated_content.txt")); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Read, |
| truncation_path, |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_create_basic() { |
| let content = "Hello, world!"; |
| let fixture = ToolOperation::FsWrite { |
| input: forge_domain::FSWrite { |
| file_path: "/home/user/new_file.txt".to_string(), |
| content: content.to_string(), |
| overwrite: false, |
| }, |
| output: FsWriteOutput { |
| path: "/home/user/new_file.txt".to_string(), |
| before: None, |
| errors: vec![], |
| content_hash: compute_hash(content), |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Write, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_create_overwrite() { |
| let content = "New content for the file"; |
| let fixture = ToolOperation::FsWrite { |
| input: forge_domain::FSWrite { |
| file_path: "/home/user/existing_file.txt".to_string(), |
| content: content.to_string(), |
| overwrite: true, |
| }, |
| output: FsWriteOutput { |
| path: "/home/user/existing_file.txt".to_string(), |
| before: Some("Old content".to_string()), |
| errors: vec![], |
| content_hash: compute_hash(content), |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
| let actual = fixture.into_tool_output( |
| ToolKind::Write, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_shell_output_no_truncation() { |
| let fixture = ToolOperation::Shell { |
| output: ShellOutput { |
| output: forge_domain::CommandOutput { |
| command: "echo hello".to_string(), |
| stdout: "hello\nworld".to_string(), |
| stderr: "".to_string(), |
| exit_code: Some(0), |
| }, |
| shell: "/bin/bash".to_string(), |
| description: None, |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
| let actual = fixture.into_tool_output( |
| ToolKind::Write, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_shell_output_stdout_truncation_only() { |
| |
| let mut stdout_lines = Vec::new(); |
| for i in 1..=25 { |
| stdout_lines.push(format!("stdout line {}", i)); |
| } |
| let stdout = stdout_lines.join("\n"); |
|
|
| let fixture = ToolOperation::Shell { |
| output: ShellOutput { |
| output: forge_domain::CommandOutput { |
| command: "long_command".to_string(), |
| stdout, |
| stderr: "".to_string(), |
| exit_code: Some(0), |
| }, |
| shell: "/bin/bash".to_string(), |
| description: None, |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
| let truncation_path = |
| TempContentFiles::default().stdout(PathBuf::from("/tmp/stdout_content.txt")); |
| let actual = fixture.into_tool_output( |
| ToolKind::Shell, |
| truncation_path, |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_shell_output_stderr_truncation_only() { |
| |
| let mut stderr_lines = Vec::new(); |
| for i in 1..=25 { |
| stderr_lines.push(format!("stderr line {}", i)); |
| } |
| let stderr = stderr_lines.join("\n"); |
|
|
| let fixture = ToolOperation::Shell { |
| output: ShellOutput { |
| output: forge_domain::CommandOutput { |
| command: "error_command".to_string(), |
| stdout: "".to_string(), |
| stderr, |
| exit_code: Some(1), |
| }, |
| shell: "/bin/bash".to_string(), |
| description: None, |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
| let truncation_path = |
| TempContentFiles::default().stderr(PathBuf::from("/tmp/stderr_content.txt")); |
| let actual = fixture.into_tool_output( |
| ToolKind::Shell, |
| truncation_path, |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_shell_output_both_stdout_stderr_truncation() { |
| |
| let mut stdout_lines = Vec::new(); |
| for i in 1..=25 { |
| stdout_lines.push(format!("stdout line {}", i)); |
| } |
| let stdout = stdout_lines.join("\n"); |
|
|
| let mut stderr_lines = Vec::new(); |
| for i in 1..=30 { |
| stderr_lines.push(format!("stderr line {}", i)); |
| } |
| let stderr = stderr_lines.join("\n"); |
|
|
| let fixture = ToolOperation::Shell { |
| output: ShellOutput { |
| output: forge_domain::CommandOutput { |
| command: "complex_command".to_string(), |
| stdout, |
| stderr, |
| exit_code: Some(0), |
| }, |
| shell: "/bin/bash".to_string(), |
| description: None, |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
| let truncation_path = TempContentFiles::default() |
| .stdout(PathBuf::from("/tmp/stdout_content.txt")) |
| .stderr(PathBuf::from("/tmp/stderr_content.txt")); |
| let actual = fixture.into_tool_output( |
| ToolKind::Shell, |
| truncation_path, |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_shell_output_exact_boundary_stdout() { |
| |
| let mut stdout_lines = Vec::new(); |
| for i in 1..=20 { |
| stdout_lines.push(format!("stdout line {}", i)); |
| } |
| let stdout = stdout_lines.join("\n"); |
|
|
| let fixture = ToolOperation::Shell { |
| output: ShellOutput { |
| output: forge_domain::CommandOutput { |
| command: "boundary_command".to_string(), |
| stdout, |
| stderr: "".to_string(), |
| exit_code: Some(0), |
| }, |
| shell: "/bin/bash".to_string(), |
| description: None, |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
| let actual = fixture.into_tool_output( |
| ToolKind::Shell, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_shell_output_single_line_each() { |
| let fixture = ToolOperation::Shell { |
| output: ShellOutput { |
| output: forge_domain::CommandOutput { |
| command: "simple_command".to_string(), |
| stdout: "single stdout line".to_string(), |
| stderr: "single stderr line".to_string(), |
| exit_code: Some(0), |
| }, |
| shell: "/bin/bash".to_string(), |
| description: None, |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
| let actual = fixture.into_tool_output( |
| ToolKind::Shell, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_shell_output_empty_streams() { |
| let fixture = ToolOperation::Shell { |
| output: ShellOutput { |
| output: forge_domain::CommandOutput { |
| command: "silent_command".to_string(), |
| stdout: "".to_string(), |
| stderr: "".to_string(), |
| exit_code: Some(0), |
| }, |
| shell: "/bin/bash".to_string(), |
| description: None, |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
| let actual = fixture.into_tool_output( |
| ToolKind::Shell, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_shell_output_line_number_calculation() { |
| |
| let mut stdout_lines = Vec::new(); |
| for i in 1..=15 { |
| stdout_lines.push(format!("stdout {}", i)); |
| } |
| let stdout = stdout_lines.join("\n"); |
|
|
| let mut stderr_lines = Vec::new(); |
| for i in 1..=12 { |
| stderr_lines.push(format!("stderr {}", i)); |
| } |
| let stderr = stderr_lines.join("\n"); |
|
|
| let fixture = ToolOperation::Shell { |
| output: ShellOutput { |
| output: forge_domain::CommandOutput { |
| command: "line_test_command".to_string(), |
| stdout, |
| stderr, |
| exit_code: Some(0), |
| }, |
| shell: "/bin/bash".to_string(), |
| description: None, |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
| let truncation_path = TempContentFiles::default() |
| .stdout(PathBuf::from("/tmp/stdout_content.txt")) |
| .stderr(PathBuf::from("/tmp/stderr_content.txt")); |
| let actual = fixture.into_tool_output( |
| ToolKind::Shell, |
| truncation_path, |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_search_output() { |
| |
| let mut matches = Vec::new(); |
| let total_lines = 50; |
| for i in 1..=total_lines { |
| matches.push(Match { |
| path: "/home/user/project/foo.txt".to_string(), |
| result: Some(MatchResult::Found { |
| line: format!("Match line {}: Test", i), |
| line_number: Some(i), |
| }), |
| }); |
| } |
|
|
| let fixture = ToolOperation::FsSearch { |
| input: forge_domain::FSSearch { |
| path: Some("/home/user/project".to_string()), |
| pattern: "search".to_string(), |
| glob: Some("*.txt".to_string()), |
| ..Default::default() |
| }, |
| output: Some(SearchResult { matches }), |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::FsSearch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_search_max_output() { |
| |
| let mut matches = Vec::new(); |
| let total_lines = 50; |
| for i in 1..=total_lines { |
| matches.push(Match { |
| path: "/home/user/project/foo.txt".to_string(), |
| result: Some(MatchResult::Found { |
| line: format!("Match line {}: Test", i), |
| line_number: Some(i), |
| }), |
| }); |
| } |
|
|
| let fixture = ToolOperation::FsSearch { |
| input: forge_domain::FSSearch { |
| path: Some("/home/user/project".to_string()), |
| pattern: "search".to_string(), |
| glob: Some("*.txt".to_string()), |
| ..Default::default() |
| }, |
| output: Some(SearchResult { matches }), |
| }; |
|
|
| let env = fixture_environment(); |
| let mut config = fixture_config(); |
| |
| config.max_search_lines = 10; |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::FsSearch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_search_min_lines_but_max_line_length() { |
| |
| let mut matches = Vec::new(); |
| let total_lines = 50; |
| for i in 1..=total_lines { |
| matches.push(Match { |
| path: "/home/user/project/foo.txt".to_string(), |
| result: Some(MatchResult::Found { |
| line: format!("Match line {}: {}", i, "AB".repeat(50)), |
| line_number: Some(i), |
| }), |
| }); |
| } |
|
|
| let fixture = ToolOperation::FsSearch { |
| input: forge_domain::FSSearch { |
| path: Some("/home/user/project".to_string()), |
| pattern: "search".to_string(), |
| glob: Some("*.txt".to_string()), |
| ..Default::default() |
| }, |
| output: Some(SearchResult { matches }), |
| }; |
|
|
| let env = fixture_environment(); |
| let mut config = fixture_config(); |
| |
| config.max_search_lines = 20; |
| let max_bytes: f64 = 0.001 * 1024.0 * 1024.0; |
| config.max_search_result_bytes = max_bytes.ceil() as usize; |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::FsSearch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_search_very_lengthy_one_line_match() { |
| let mut matches = Vec::new(); |
| let total_lines = 1; |
| for i in 1..=total_lines { |
| matches.push(Match { |
| path: "/home/user/project/foo.txt".to_string(), |
| result: Some(MatchResult::Found { |
| line: format!( |
| "Match line {}: {}", |
| i, |
| "abcdefghijklmnopqrstuvwxyz".repeat(40) |
| ), |
| line_number: Some(i), |
| }), |
| }); |
| } |
|
|
| let fixture = ToolOperation::FsSearch { |
| input: forge_domain::FSSearch { |
| path: Some("/home/user/project".to_string()), |
| pattern: "search".to_string(), |
| glob: Some("*.txt".to_string()), |
| ..Default::default() |
| }, |
| output: Some(SearchResult { matches }), |
| }; |
|
|
| let env = fixture_environment(); |
| let mut config = fixture_config(); |
| |
| config.max_search_lines = 20; |
| let max_bytes: f64 = 0.001 * 1024.0 * 1024.0; |
| config.max_search_result_bytes = max_bytes.ceil() as usize; |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::FsSearch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_search_no_matches() { |
| let fixture = ToolOperation::FsSearch { |
| input: forge_domain::FSSearch { |
| path: Some("/home/user/empty_project".to_string()), |
| pattern: "nonexistent".to_string(), |
| ..Default::default() |
| }, |
| output: None, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::FsSearch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_create_with_warning() { |
| let content = "Content with warning"; |
| let fixture = ToolOperation::FsWrite { |
| input: forge_domain::FSWrite { |
| file_path: "/home/user/file_with_warning.txt".to_string(), |
| content: content.to_string(), |
| overwrite: false, |
| }, |
| output: FsWriteOutput { |
| path: "/home/user/file_with_warning.txt".to_string(), |
| before: None, |
| errors: test_syntax_errors(vec![(10, 5, "Syntax error on line 10")]), |
| content_hash: compute_hash(content), |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Write, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_create_with_warning_xml_tags() { |
| let content = "Content with warning"; |
| let fixture = ToolOperation::FsWrite { |
| input: forge_domain::FSWrite { |
| file_path: "/home/user/file_with_warning.txt".to_string(), |
| content: content.to_string(), |
| overwrite: false, |
| }, |
| output: FsWriteOutput { |
| path: "/home/user/file_with_warning.txt".to_string(), |
| before: None, |
| errors: test_syntax_errors(vec![ |
| (10, 5, "Syntax error on line 10"), |
| (20, 15, "Missing semicolon"), |
| ]), |
| content_hash: compute_hash(content), |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Write, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_remove_success() { |
| let fixture = ToolOperation::FsRemove { |
| input: forge_domain::FSRemove { path: "/home/user/file_to_delete.txt".to_string() }, |
| output: FsRemoveOutput { content: "content".to_string() }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Remove, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_search_with_results() { |
| let fixture = ToolOperation::FsSearch { |
| input: forge_domain::FSSearch { |
| path: Some("/home/user/project".to_string()), |
| pattern: "Hello".to_string(), |
| glob: Some("*.txt".to_string()), |
| ..Default::default() |
| }, |
| output: Some(SearchResult { |
| matches: vec![ |
| Match { |
| path: "file1.txt".to_string(), |
| result: Some(MatchResult::Found { |
| line_number: Some(1), |
| line: "Hello world".to_string(), |
| }), |
| }, |
| Match { |
| path: "file2.txt".to_string(), |
| result: Some(MatchResult::Found { |
| line_number: Some(3), |
| line: "Hello universe".to_string(), |
| }), |
| }, |
| ], |
| }), |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::FsSearch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_search_with_offset() { |
| |
| let mut matches = Vec::new(); |
| let total_lines = 50; |
| for i in 1..=total_lines { |
| matches.push(Match { |
| path: "/home/user/project/foo.txt".to_string(), |
| result: Some(MatchResult::Found { |
| line: format!("Match line {}: Test", i), |
| line_number: Some(i), |
| }), |
| }); |
| } |
|
|
| let fixture = ToolOperation::FsSearch { |
| input: forge_domain::FSSearch { |
| path: Some("/home/user/project".to_string()), |
| pattern: "search".to_string(), |
| glob: Some("*.txt".to_string()), |
| offset: Some(10), |
| head_limit: Some(15), |
| ..Default::default() |
| }, |
| output: Some(SearchResult { matches }), |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::FsSearch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_search_files_with_matches_mode() { |
| let fixture = ToolOperation::FsSearch { |
| input: forge_domain::FSSearch { |
| path: Some("/home/user/project".to_string()), |
| pattern: "test".to_string(), |
| output_mode: Some(forge_domain::OutputMode::FilesWithMatches), |
| ..Default::default() |
| }, |
| output: Some(SearchResult { |
| matches: vec![ |
| Match { |
| path: "/home/user/project/file1.rs".to_string(), |
| result: Some(MatchResult::FileMatch), |
| }, |
| Match { |
| path: "/home/user/project/file2.rs".to_string(), |
| result: Some(MatchResult::FileMatch), |
| }, |
| ], |
| }), |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::FsSearch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_search_count_mode() { |
| let fixture = ToolOperation::FsSearch { |
| input: forge_domain::FSSearch { |
| path: Some("/home/user/project".to_string()), |
| pattern: "test".to_string(), |
| output_mode: Some(forge_domain::OutputMode::Count), |
| ..Default::default() |
| }, |
| output: Some(SearchResult { |
| matches: vec![ |
| Match { |
| path: "/home/user/project/file1.rs".to_string(), |
| result: Some(MatchResult::Count { count: 5 }), |
| }, |
| Match { |
| path: "/home/user/project/file2.rs".to_string(), |
| result: Some(MatchResult::Count { count: 3 }), |
| }, |
| Match { |
| path: "/home/user/project/file3.rs".to_string(), |
| result: Some(MatchResult::Count { count: 12 }), |
| }, |
| ], |
| }), |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::FsSearch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_search_with_context_lines() { |
| let fixture = ToolOperation::FsSearch { |
| input: forge_domain::FSSearch { |
| path: Some("/home/user/project".to_string()), |
| pattern: "MATCH".to_string(), |
| context: Some(2), |
| ..Default::default() |
| }, |
| output: Some(SearchResult { |
| matches: vec![Match { |
| path: "/home/user/project/test.txt".to_string(), |
| result: Some(MatchResult::ContextMatch { |
| line_number: Some(10), |
| line: "This is the MATCH line".to_string(), |
| before_context: vec![ |
| "line 8 before context".to_string(), |
| "line 9 before context".to_string(), |
| ], |
| after_context: vec![ |
| "line 11 after context".to_string(), |
| "line 12 after context".to_string(), |
| ], |
| }), |
| }], |
| }), |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::FsSearch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_search_with_before_context() { |
| let fixture = ToolOperation::FsSearch { |
| input: forge_domain::FSSearch { |
| path: Some("/home/user/project".to_string()), |
| pattern: "ERROR".to_string(), |
| before_context: Some(3), |
| ..Default::default() |
| }, |
| output: Some(SearchResult { |
| matches: vec![Match { |
| path: "/home/user/project/log.txt".to_string(), |
| result: Some(MatchResult::ContextMatch { |
| line_number: Some(50), |
| line: "ERROR: Something went wrong".to_string(), |
| before_context: vec![ |
| "line 47: INFO startup".to_string(), |
| "line 48: DEBUG processing".to_string(), |
| "line 49: WARN slow operation".to_string(), |
| ], |
| after_context: vec![], |
| }), |
| }], |
| }), |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::FsSearch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_search_with_after_context() { |
| let fixture = ToolOperation::FsSearch { |
| input: forge_domain::FSSearch { |
| path: Some("/home/user/project".to_string()), |
| pattern: "TODO".to_string(), |
| after_context: Some(2), |
| ..Default::default() |
| }, |
| output: Some(SearchResult { |
| matches: vec![Match { |
| path: "/home/user/project/src/main.rs".to_string(), |
| result: Some(MatchResult::ContextMatch { |
| line_number: Some(15), |
| line: "// TODO: Implement this feature".to_string(), |
| before_context: vec![], |
| after_context: vec![ |
| "fn main() {".to_string(), |
| " println!(\"Hello\");".to_string(), |
| ], |
| }), |
| }], |
| }), |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::FsSearch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_search_without_line_numbers() { |
| let fixture = ToolOperation::FsSearch { |
| input: forge_domain::FSSearch { |
| path: Some("/home/user/project".to_string()), |
| pattern: "function".to_string(), |
| show_line_numbers: Some(false), |
| ..Default::default() |
| }, |
| output: Some(SearchResult { |
| matches: vec![ |
| Match { |
| path: "/home/user/project/app.js".to_string(), |
| result: Some(MatchResult::Found { |
| line_number: None, |
| line: "function doSomething() {".to_string(), |
| }), |
| }, |
| Match { |
| path: "/home/user/project/utils.js".to_string(), |
| result: Some(MatchResult::Found { |
| line_number: None, |
| line: "function helper() {".to_string(), |
| }), |
| }, |
| ], |
| }), |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::FsSearch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_search_with_file_type() { |
| let fixture = ToolOperation::FsSearch { |
| input: forge_domain::FSSearch { |
| path: Some("/home/user/project".to_string()), |
| pattern: "class".to_string(), |
| file_type: Some("py".to_string()), |
| ..Default::default() |
| }, |
| output: Some(SearchResult { |
| matches: vec![ |
| Match { |
| path: "/home/user/project/models.py".to_string(), |
| result: Some(MatchResult::Found { |
| line_number: Some(1), |
| line: "class User:".to_string(), |
| }), |
| }, |
| Match { |
| path: "/home/user/project/views.py".to_string(), |
| result: Some(MatchResult::Found { |
| line_number: Some(5), |
| line: "class HomeView:".to_string(), |
| }), |
| }, |
| ], |
| }), |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::FsSearch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_search_case_insensitive() { |
| let fixture = ToolOperation::FsSearch { |
| input: forge_domain::FSSearch { |
| path: Some("/home/user/project".to_string()), |
| pattern: "error".to_string(), |
| case_insensitive: Some(true), |
| ..Default::default() |
| }, |
| output: Some(SearchResult { |
| matches: vec![ |
| Match { |
| path: "/home/user/project/log.txt".to_string(), |
| result: Some(MatchResult::Found { |
| line_number: Some(10), |
| line: "ERROR: Connection failed".to_string(), |
| }), |
| }, |
| Match { |
| path: "/home/user/project/log.txt".to_string(), |
| result: Some(MatchResult::Found { |
| line_number: Some(15), |
| line: "error in processing".to_string(), |
| }), |
| }, |
| Match { |
| path: "/home/user/project/log.txt".to_string(), |
| result: Some(MatchResult::Found { |
| line_number: Some(20), |
| line: "Error: Invalid input".to_string(), |
| }), |
| }, |
| ], |
| }), |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::FsSearch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_search_multiline_pattern() { |
| let fixture = ToolOperation::FsSearch { |
| input: forge_domain::FSSearch { |
| path: Some("/home/user/project".to_string()), |
| pattern: "struct.*\\{.*field".to_string(), |
| multiline: Some(true), |
| ..Default::default() |
| }, |
| output: Some(SearchResult { |
| matches: vec![Match { |
| path: "/home/user/project/types.rs".to_string(), |
| result: Some(MatchResult::Found { |
| line_number: Some(10), |
| line: "struct User {\n field: String".to_string(), |
| }), |
| }], |
| }), |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::FsSearch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_search_no_results() { |
| let fixture = ToolOperation::FsSearch { |
| input: forge_domain::FSSearch { |
| path: Some("/home/user/project".to_string()), |
| pattern: "NonExistentPattern".to_string(), |
| ..Default::default() |
| }, |
| output: None, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::FsSearch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_patch_basic() { |
| let after_content = "Hello universe\nThis is a test"; |
| let fixture = ToolOperation::FsPatch { |
| input: forge_domain::FSPatch { |
| file_path: "/home/user/test.txt".to_string(), |
| old_string: "world".to_string(), |
| new_string: "universe".to_string(), |
| replace_all: false, |
| }, |
| output: PatchOutput { |
| errors: vec![], |
| before: "Hello world\nThis is a test".to_string(), |
| after: after_content.to_string(), |
| content_hash: compute_hash(after_content), |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Patch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_patch_with_warning() { |
| let after_content = "line1\nnew line\nline2"; |
| let fixture = ToolOperation::FsPatch { |
| input: forge_domain::FSPatch { |
| file_path: "/home/user/large_file.txt".to_string(), |
| old_string: "line1".to_string(), |
| new_string: "\nnew line".to_string(), |
| replace_all: false, |
| }, |
| output: PatchOutput { |
| errors: test_syntax_errors(vec![(5, 10, "Invalid syntax")]), |
| before: "line1\nline2".to_string(), |
| after: after_content.to_string(), |
| content_hash: compute_hash(after_content), |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Patch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_patch_with_warning_special_chars() { |
| let after_content = "line1\nnew line\nline2"; |
| let fixture = ToolOperation::FsPatch { |
| input: forge_domain::FSPatch { |
| file_path: "/home/user/test.zsh".to_string(), |
| old_string: "line1".to_string(), |
| new_string: "\nnew line".to_string(), |
| replace_all: false, |
| }, |
| output: PatchOutput { |
| errors: test_syntax_errors(vec![ |
| ( |
| 22, |
| 1, |
| r#"Syntax error at 'function dim() { echo "${_DIM}${1}${RESET}"'"#, |
| ), |
| (25, 5, "Unexpected token"), |
| (30, 10, "Missing closing brace"), |
| ]), |
| before: "line1\nline2".to_string(), |
| after: after_content.to_string(), |
| content_hash: compute_hash(after_content), |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Patch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_undo_no_changes() { |
| let fixture = ToolOperation::FsUndo { |
| input: forge_domain::FSUndo { path: "/home/user/unchanged_file.txt".to_string() }, |
| output: FsUndoOutput { before_undo: None, after_undo: None }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Undo, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_undo_file_created() { |
| let fixture = ToolOperation::FsUndo { |
| input: forge_domain::FSUndo { path: "/home/user/new_file.txt".to_string() }, |
| output: FsUndoOutput { |
| before_undo: None, |
| after_undo: Some("New file content\nLine 2\nLine 3".to_string()), |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Undo, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_undo_file_removed() { |
| let fixture = ToolOperation::FsUndo { |
| input: forge_domain::FSUndo { path: "/home/user/deleted_file.txt".to_string() }, |
| output: FsUndoOutput { |
| before_undo: Some( |
| "Original file content\nThat was deleted\nDuring undo".to_string(), |
| ), |
| after_undo: None, |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Undo, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_undo_file_restored() { |
| let fixture = ToolOperation::FsUndo { |
| input: forge_domain::FSUndo { path: "/home/user/restored_file.txt".to_string() }, |
| output: FsUndoOutput { |
| before_undo: Some("Original content\nBefore changes".to_string()), |
| after_undo: Some("Modified content\nAfter restoration".to_string()), |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Undo, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_undo_success() { |
| let fixture = ToolOperation::FsUndo { |
| input: forge_domain::FSUndo { path: "/home/user/test.txt".to_string() }, |
| output: FsUndoOutput { |
| before_undo: Some("ABC".to_string()), |
| after_undo: Some("PQR".to_string()), |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Undo, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_net_fetch_success() { |
| let fixture = ToolOperation::NetFetch { |
| input: forge_domain::NetFetch { |
| url: "https://example.com".to_string(), |
| raw: Some(false), |
| }, |
| output: HttpResponse { |
| content: "# Example Website\n\nThis is some content from a website.".to_string(), |
| code: 200, |
| context: ResponseContext::Raw, |
| content_type: "text/plain".to_string(), |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Fetch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_net_fetch_truncated() { |
| let env = fixture_environment(); |
| let config = fixture_config(); |
| let truncated_content = "Truncated Content".to_string(); |
| let long_content = format!( |
| "{}{}", |
| "A".repeat(config.max_fetch_chars), |
| truncated_content |
| ); |
| let fixture = ToolOperation::NetFetch { |
| input: forge_domain::NetFetch { |
| url: "https://example.com/large-page".to_string(), |
| raw: Some(false), |
| }, |
| output: HttpResponse { |
| content: long_content, |
| code: 200, |
| context: ResponseContext::Parsed, |
| content_type: "text/html".to_string(), |
| }, |
| }; |
|
|
| let truncation_path = |
| TempContentFiles::default().stdout(PathBuf::from("/tmp/forge_fetch_abc123.txt")); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Fetch, |
| truncation_path, |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| |
| assert!( |
| !actual |
| .values |
| .first() |
| .unwrap() |
| .as_str() |
| .unwrap() |
| .ends_with(&truncated_content) |
| ); |
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_shell_success() { |
| let fixture = ToolOperation::Shell { |
| output: ShellOutput { |
| output: forge_domain::CommandOutput { |
| command: "ls -la".to_string(), |
| stdout: "total 8\ndrwxr-xr-x 2 user user 4096 Jan 1 12:00 .\ndrwxr-xr-x 10 user user 4096 Jan 1 12:00 ..".to_string(), |
| stderr: "".to_string(), |
| exit_code: Some(0), |
| }, |
| shell: "/bin/bash".to_string(), |
| description: None, |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Shell, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_shell_with_description() { |
| let fixture = ToolOperation::Shell { |
| output: ShellOutput { |
| output: forge_domain::CommandOutput { |
| command: "git status".to_string(), |
| stdout: "On branch main\nnothing to commit, working tree clean".to_string(), |
| stderr: "".to_string(), |
| exit_code: Some(0), |
| }, |
| shell: "/bin/bash".to_string(), |
| description: Some("Shows working tree status".to_string()), |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Shell, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_follow_up_with_question() { |
| let fixture = ToolOperation::FollowUp { |
| output: Some("Which file would you like to edit?".to_string()), |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Followup, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_sem_search_with_results() { |
| use sem_search_helpers::{chunk_node, search_results}; |
|
|
| let fixture = ToolOperation::CodebaseSearch { |
| output: search_results( |
| "retry mechanism with exponential backoff", |
| "where is the retrying logic written", |
| vec![ |
| chunk_node( |
| "src/retry.rs", |
| "fn retry_with_backoff(max_attempts: u32) {\n let mut delay = 100;\n for attempt in 0..max_attempts {\n if try_operation().is_ok() {\n return;\n }\n thread::sleep(Duration::from_millis(delay));\n delay *= 2;\n }\n}", |
| 10, |
| ), |
| chunk_node( |
| "src/http/client.rs", |
| "async fn request_with_retry(&self, url: &str) -> Result<Response> {\n const MAX_RETRIES: usize = 3;\n let mut backoff = ExponentialBackoff::default();\n // Implementation...\n}", |
| 45, |
| ), |
| ], |
| ), |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
| let actual = fixture.into_tool_output( |
| ToolKind::SemSearch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_sem_search_with_usecase() { |
| use sem_search_helpers::{chunk_node, search_results}; |
|
|
| let fixture = ToolOperation::CodebaseSearch { |
| output: search_results( |
| "authentication logic", |
| "need to add similar auth to my endpoint", |
| vec![chunk_node( |
| "src/auth.rs", |
| "fn authenticate_user(token: &str) -> Result<User> {\n verify_jwt(token)\n}", |
| 10, |
| )], |
| ), |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
| let actual = fixture.into_tool_output( |
| ToolKind::SemSearch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_follow_up_no_question() { |
| let fixture = ToolOperation::FollowUp { output: None }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Followup, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_sem_search_multiple_chunks_same_file_sorted() { |
| use sem_search_helpers::{chunk_node, search_results}; |
|
|
| |
| |
| let fixture = ToolOperation::CodebaseSearch { |
| output: search_results( |
| "database operations", |
| "finding all database query implementations", |
| vec![ |
| |
| chunk_node( |
| "src/database.rs", |
| "fn delete_user(id: u32) -> Result<()> {\n db.execute(\"DELETE FROM users WHERE id = ?\", &[id])\n}", |
| 100, |
| ), |
| |
| chunk_node( |
| "src/database.rs", |
| "fn get_user(id: u32) -> Result<User> {\n db.query(\"SELECT * FROM users WHERE id = ?\", &[id])\n}", |
| 10, |
| ), |
| |
| chunk_node( |
| "src/database.rs", |
| "fn update_user(id: u32, name: &str) -> Result<()> {\n db.execute(\"UPDATE users SET name = ? WHERE id = ?\", &[name, id])\n}", |
| 50, |
| ), |
| ], |
| ), |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
| let actual = fixture.into_tool_output( |
| ToolKind::SemSearch, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_skill_operation() { |
| let fixture = ToolOperation::Skill { |
| output: forge_domain::Skill::new( |
| "test-skill", |
| "This is a test skill command with instructions", |
| "A test skill for demonstration", |
| ) |
| .path("/home/user/.forge/skills/test-skill") |
| .resources(vec![ |
| PathBuf::from("/home/user/.forge/skills/test-skill/resource1.txt"), |
| PathBuf::from("/home/user/.forge/skills/test-skill/resource2.md"), |
| ]), |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
|
|
| let actual = fixture.into_tool_output( |
| ToolKind::Skill, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| insta::assert_snapshot!(to_value(actual)); |
| } |
|
|
| #[test] |
| fn test_fs_read_image_with_vision_model() { |
| use forge_domain::Image; |
|
|
| let fixture = ToolOperation::FsRead { |
| input: FSRead { |
| file_path: "/home/user/test.png".to_string(), |
| range: None, |
| show_line_numbers: true, |
| }, |
| output: ReadOutput { |
| content: Content::image(Image::new_base64( |
| "base64_image_data".to_string(), |
| "image/png", |
| )), |
| info: FileInfo::new(1, 1, 1, "hash123".to_string()), |
| }, |
| }; |
|
|
| let env = fixture_environment(); |
| let config = fixture_config(); |
| let actual = fixture.into_tool_output( |
| ToolKind::Read, |
| TempContentFiles::default(), |
| &env, |
| &config, |
| &mut Metrics::default(), |
| ); |
|
|
| |
| assert!(!actual.values.is_empty(), "Expected non-empty output"); |
| match &actual.values[0] { |
| forge_domain::ToolValue::Image(_) => (), |
| _ => panic!("Expected image output for vision model"), |
| } |
| } |
| } |
|
|