Download crates/forge_app/src/operation.rs from SaylorTwift/forgecode: direct link, hf CLI and curl.
- Browser
- Download file 91.5 kB
-
https://huggingface.co/SaylorTwift/forgecode/resolve/main/crates/forge_app/src/operation.rs
- Command line
-
hf download hf://SaylorTwift/forgecode/crates/forge_app/src/operation.rs
-
curl -L -o operation.rs https://huggingface.co/SaylorTwift/forgecode/resolve/main/crates/forge_app/src/operation.rs
91.5 kB
| 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, | |
| }; | |
| pub struct TempContentFiles { | |
| stdout: Option<PathBuf>, | |
| stderr: Option<PathBuf>, | |
| } | |
| 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>, | |
| }, | |
| } | |
| /// Trait for stream elements that can be converted to XML elements | |
| 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 | |
| } | |
| } | |
| /// Helper function to create stdout or stderr elements with consistent | |
| /// structure | |
| 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) | |
| } | |
| /// Creates a validation warning element for syntax errors | |
| /// | |
| /// # Arguments | |
| /// * `path` - The file path | |
| /// * `errors` - Vector of syntax errors | |
| /// | |
| /// Returns an Element containing the formatted warning with all error details | |
| 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 { | |
| /// Converts this tool operation into a [`forge_domain::ToolOutput`]. | |
| /// | |
| /// # Arguments | |
| /// * `tool_kind` - The kind of tool that produced this operation. | |
| /// * `content_files` - Paths to any temporary truncated content files. | |
| /// * `env` - Slim runtime environment (used for `cwd` and `shell`). | |
| /// * `config` - Full application configuration (used for limits and | |
| /// thresholds). | |
| /// * `metrics` - Mutable reference to the conversation metrics that will be | |
| /// updated. | |
| 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 } => { | |
| // Check if content is an image (visual content) | |
| if let Some(image) = output.content.as_image() { | |
| // Track read operations for visual content | |
| 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()); | |
| } | |
| // Handle text content | |
| 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); | |
| // Track read operations | |
| 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 } => { | |
| // None since file was removed | |
| 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 { | |
| // Use 1-based indexing for display (humans count from 1) | |
| format!("{}-{}", truncated_output.start + 1, truncated_output.end) | |
| } else { | |
| // No matches or empty result | |
| "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(); | |
| // Extract all file chunks and group by path | |
| 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); | |
| } | |
| } | |
| // Sort by file path for stable ordering | |
| 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(); | |
| // Process each file path | |
| for (path, mut chunks) in grouped_chunks { | |
| // Sort chunks by start line | |
| 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 } => { | |
| // Diff between snapshot state (after_undo) and modified state | |
| // (before_undo) | |
| 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)) => { | |
| // This diff is between modified state (before_undo) and snapshot | |
| // state (after_undo) | |
| 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) | |
| }); | |
| // Insert Resources | |
| 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 } => { | |
| // Build a map of before todos by ID for diff computation | |
| 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) | |
| } | |
| } | |
| } | |
| } | |
| 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")) // Set deterministic cwd to avoid flaky path formatting | |
| } | |
| fn fixture_config() -> ForgeConfig { | |
| let max_bytes: f64 = 250.0 * 1024.0; // 250 KB | |
| 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, // 256 KiB | |
| ..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 | |
| } | |
| /// Creates test syntax errors for testing purposes | |
| 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() | |
| } | |
| // Helper functions for semantic search tests | |
| mod sem_search_helpers { | |
| use fake::{Fake, Faker}; | |
| use forge_domain::{CodebaseQueryResult, CodebaseSearchResults, FileChunk, Node, NodeData}; | |
| /// Creates a file chunk node with auto-generated ID, computed end_line, | |
| /// and default relevance | |
| /// | |
| /// # Arguments | |
| /// * `file_path` - Path to the file | |
| /// * `content` - Code content | |
| /// * `start_line` - Starting line number | |
| /// | |
| /// The end_line is computed from content by counting newlines. | |
| /// Node ID is auto-generated using faker. | |
| /// Relevance defaults to 0.9. | |
| 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), | |
| } | |
| } | |
| /// Creates a CodebaseSearchResults with a single query | |
| 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, | |
| }], | |
| } | |
| } | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| fn test_shell_output_stdout_truncation_only() { | |
| // Create stdout with more lines than the truncation limit | |
| 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)); | |
| } | |
| fn test_shell_output_stderr_truncation_only() { | |
| // Create stderr with more lines than the truncation limit | |
| 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)); | |
| } | |
| fn test_shell_output_both_stdout_stderr_truncation() { | |
| // Create both stdout and stderr with more lines than the truncation limit | |
| 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)); | |
| } | |
| fn test_shell_output_exact_boundary_stdout() { | |
| // Create stdout with exactly the truncation limit (prefix + suffix = 20 lines) | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| fn test_shell_output_line_number_calculation() { | |
| // Test specific line number calculations for 1-based indexing | |
| 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)); | |
| } | |
| fn test_fs_search_output() { | |
| // Create a large number of search matches to trigger truncation | |
| 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(); // max_search_lines is 25 | |
| 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)); | |
| } | |
| fn test_fs_search_max_output() { | |
| // Create a large number of search matches to trigger truncation | |
| let mut matches = Vec::new(); | |
| let total_lines = 50; // Total lines found. | |
| 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(); | |
| // Total lines found are 50, but we limit to 10 for this test | |
| 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)); | |
| } | |
| fn test_fs_search_min_lines_but_max_line_length() { | |
| // Create a large number of search matches to trigger truncation | |
| let mut matches = Vec::new(); | |
| let total_lines = 50; // Total lines found. | |
| 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(); | |
| // Total lines found are 50, but we limit to 20 for this test | |
| 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; // limit to 0.001 MB | |
| let actual = fixture.into_tool_output( | |
| ToolKind::FsSearch, | |
| TempContentFiles::default(), | |
| &env, | |
| &config, | |
| &mut Metrics::default(), | |
| ); | |
| insta::assert_snapshot!(to_value(actual)); | |
| } | |
| fn test_fs_search_very_lengthy_one_line_match() { | |
| let mut matches = Vec::new(); | |
| let total_lines = 1; // Total lines found. | |
| 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(); | |
| // Total lines found are 50, but we limit to 20 for this test | |
| 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; // limit to 0.001 MB | |
| let actual = fixture.into_tool_output( | |
| ToolKind::FsSearch, | |
| TempContentFiles::default(), | |
| &env, | |
| &config, | |
| &mut Metrics::default(), | |
| ); | |
| insta::assert_snapshot!(to_value(actual)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| fn test_fs_search_with_offset() { | |
| // Create 50 matches to test offset and pagination | |
| 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), // Skip first 10 matches | |
| head_limit: Some(15), // Take 15 matches after offset | |
| ..Default::default() | |
| }, | |
| output: Some(SearchResult { matches }), | |
| }; | |
| let env = fixture_environment(); // max_search_lines is 25 | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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), // 2 lines before and after | |
| ..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)); | |
| } | |
| 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), // 3 lines before | |
| ..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)); | |
| } | |
| 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), // 2 lines after | |
| ..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)); | |
| } | |
| 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, // No line number when disabled | |
| 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)); | |
| } | |
| 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()), // Python files only | |
| ..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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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(), | |
| ); | |
| // make sure that the content is truncated | |
| assert!( | |
| !actual | |
| .values | |
| .first() | |
| .unwrap() | |
| .as_str() | |
| .unwrap() | |
| .ends_with(&truncated_content) | |
| ); | |
| insta::assert_snapshot!(to_value(actual)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| fn test_sem_search_multiple_chunks_same_file_sorted() { | |
| use sem_search_helpers::{chunk_node, search_results}; | |
| // Test that multiple chunks from the same file are sorted by start_line | |
| // Chunks are provided in non-sequential order: 100, 10, 50 | |
| let fixture = ToolOperation::CodebaseSearch { | |
| output: search_results( | |
| "database operations", | |
| "finding all database query implementations", | |
| vec![ | |
| // Third chunk (lines 100-102) - provided first | |
| chunk_node( | |
| "src/database.rs", | |
| "fn delete_user(id: u32) -> Result<()> {\n db.execute(\"DELETE FROM users WHERE id = ?\", &[id])\n}", | |
| 100, | |
| ), | |
| // First chunk (lines 10-12) - provided second | |
| chunk_node( | |
| "src/database.rs", | |
| "fn get_user(id: u32) -> Result<User> {\n db.query(\"SELECT * FROM users WHERE id = ?\", &[id])\n}", | |
| 10, | |
| ), | |
| // Second chunk (lines 50-52) - provided third | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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(), | |
| ); | |
| // Should return image content | |
| assert!(!actual.values.is_empty(), "Expected non-empty output"); | |
| match &actual.values[0] { | |
| forge_domain::ToolValue::Image(_) => (), // Expected | |
| _ => panic!("Expected image output for vision model"), | |
| } | |
| } | |
| } | |