//! `hashline_grep` — anchor-annotated search results. //! //! Delegates to the standard `GrepTool` for ripgrep execution, then //! post-processes content-mode output to inject scheme-aware anchors. //! Enables grep → edit workflows without an intermediate file read. use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::time::Duration; use crate::computer::types::AsyncFileSystem; use crate::implementations::kigi::grep::{GrepSearchInput, GrepTool, OutputMode}; use crate::types::output::GrepSearchOutput; use crate::types::requirements::{Expr, ToolRequirement}; use crate::types::resources::Params; use crate::types::tool::{ToolKind, ToolNamespace}; use super::anchor::split_lines; use super::config::HashlineSchemeParams; use super::scheme::{Anchor, AnchorScheme}; const DEFAULT_ANCHOR_TIMEOUT_SECS: u64 = 60; async fn get_or_generate<'a>( cache: &'a mut HashMap>, path: &Path, scheme: &dyn AnchorScheme, fs: &dyn AsyncFileSystem, ) -> Option<&'a [Anchor]> { if !cache.contains_key(path) { let bytes = fs.read_file(path).await.ok()?; let content = String::from_utf8_lossy(&bytes); let lines = split_lines(&content); cache.insert(path.to_path_buf(), scheme.generate_anchors(&lines)); } cache.get(path).map(|v| v.as_slice()) } /// Inject anchors into ripgrep content-mode output. /// /// Transforms lines like `123: let x = 1;` or `124- let y = 2;` /// into `123:abc:rst: let x = 1;` or `124:abc:rst- let y = 2;`. /// /// Lines that are file headers or separators pass through unchanged. pub(crate) async fn inject_anchors( stdout_bytes: &[u8], cwd: &Path, fs: &dyn AsyncFileSystem, scheme: &dyn AnchorScheme, ) -> Vec { let stdout = String::from_utf8_lossy(stdout_bytes); let (prefix, body, suffix) = match (stdout.find(">\n"), stdout.rfind("\n")) { (Some(start), Some(end)) => { let body_start = start + 2; ( &stdout[..body_start], &stdout[body_start..end], &stdout[end..], ) } _ => return stdout_bytes.to_vec(), }; let mut file_anchors: HashMap> = HashMap::new(); let mut result = String::from(prefix); let mut current_file: Option = None; for line in body.lines() { if !result.ends_with('\n') && !result.ends_with('>') { result.push('\n'); } // Group separators (--) pass through. if line == "--" { result.push_str(line); continue; } // Try to parse as a numbered match/context line first. // This correctly handles file paths that start with digits (e.g. // "2024_migration.rs") — they won't parse as valid rg lines because // they lack a ':' or '-' separator after the numeric prefix. if let Some((line_num, separator, content)) = parse_rg_line(line) && let Some(ref file_path) = current_file && let Some(anchors) = get_or_generate(&mut file_anchors, file_path, scheme, fs).await && line_num.saturating_sub(1) < anchors.len() { let a = &anchors[line_num - 1]; let suffix_str = match &a.context { Some(ctx) => format!("{}:{ctx}", a.local), None => a.local.clone(), }; result.push_str(&format!("{line_num}:{suffix_str}{separator}{content}")); continue; } // Not a numbered line — could be a file header, summary, or // a numbered line that failed anchor lookup (passed through below). if parse_rg_line(line).is_none() && !line.starts_with("Found ") && !line.starts_with("... [") && !line.is_empty() { current_file = Some(cwd.join(line)); } result.push_str(line); } result.push_str(suffix); result.into_bytes() } /// Parse a ripgrep numbered line: `123:content` or `45-context`. fn parse_rg_line(line: &str) -> Option<(usize, char, &str)> { let bytes = line.as_bytes(); let mut idx = 0; while idx < bytes.len() && bytes[idx].is_ascii_digit() { idx += 1; } if idx == 0 || idx >= bytes.len() { return None; } let sep = bytes[idx] as char; if sep != ':' && sep != '-' { return None; } let num: usize = line[..idx].parse().ok()?; Some((num, sep, &line[idx + 1..])) } const DESCRIPTION: &str = r#"Search file contents with anchor-annotated results for use with ${{ tools.by_kind.edit }}. Match lines include anchors you can pass directly to ${{ tools.by_kind.edit }} without needing to ${{ tools.by_kind.read }} the file first. Unlike ${{ tools.by_kind.read }}, this grep format keeps grep-style separators after the anchor: `:` for match lines and `-` for context lines. Content output format: {grep_match} ← match (:) {grep_context} ← context (-) Usage: - Pattern is a regex: `log.*Error`, `function\s+\w+`, `TODO` - Output modes: "content" (default, with anchors), "files_with_matches", "count" - Use -A, -B, -C for context lines around matches - Only use 'type' or 'glob' when certain of the file type - Results are capped; truncated results show "at least" counts"#; #[derive(Debug, Default)] pub struct HashlineGrepTool; impl crate::types::tool_metadata::ToolMetadata for HashlineGrepTool { fn kind(&self) -> ToolKind { ToolKind::Search } fn tool_namespace(&self) -> ToolNamespace { ToolNamespace::KigiHashline } fn description_template(&self) -> &str { DESCRIPTION } fn versioned_definition( &self, _contract_version: Option<&str>, client_name: &str, description_override: Option<&str>, renderer: &crate::types::template_renderer::TemplateRenderer, param_map: &std::collections::HashMap, input_schema: &serde_json::Value, effective_params: &serde_json::Value, ) -> crate::types::definition::ToolDefinition { let params: HashlineSchemeParams = serde_json::from_value(effective_params.clone()).unwrap_or_default(); params.build_tool_definition( DESCRIPTION, client_name, description_override, renderer, param_map, input_schema, ) } fn requires_expr(&self) -> Expr { Expr::True } } impl kigi_tool_runtime::Tool for HashlineGrepTool { type Args = GrepSearchInput; type Output = GrepSearchOutput; fn id(&self) -> kigi_tool_protocol::ToolId { kigi_tool_protocol::ToolId::new("hashline_grep").expect("valid tool id") } fn description( &self, _ctx: &::kigi_tool_runtime::ListToolsContext, ) -> kigi_tool_types::ToolDescription { kigi_tool_types::ToolDescription::new( "hashline_grep", crate::types::tool_metadata::ToolMetadata::description_template(self), ) } fn capabilities(&self) -> kigi_tool_protocol::ToolCapabilities { kigi_tool_protocol::ToolCapabilities { is_read_only: true, tool_scope: Some(kigi_tool_protocol::ToolScope::Read), ..Default::default() } } #[tracing::instrument( name = "tool.hashline_grep", skip_all, fields(timed_out = tracing::field::Empty) )] async fn run( &self, ctx: kigi_tool_runtime::ToolCallContext, input: GrepSearchInput, ) -> Result { use crate::types::tool_metadata::shared_resources; let resources = shared_resources(&ctx)?; let output_mode = input.output_mode.clone().unwrap_or(OutputMode::Content); let grep = GrepTool; let cwd = crate::types::tool_metadata::resolve_cwd(&ctx, &resources).await?; let call_id = kigi_tool_protocol::ToolCallId::new_v7(); let mut rt_ctx = kigi_tool_runtime::ToolCallContext::new(call_id); rt_ctx.extensions.insert(resources.clone()); rt_ctx.extensions.insert(kigi_tool_runtime::Cwd(cwd)); let mut result = kigi_tool_runtime::Tool::run(&grep, rt_ctx, input) .await .map_err(|e| { kigi_tool_runtime::ToolError::execution( kigi_tool_protocol::ToolId::new("grep").expect("valid"), e.to_string(), ) })?; if matches!(output_mode, OutputMode::Content) && result.exit_code == 0 { let cwd = crate::types::tool_metadata::resolve_cwd(&ctx, &resources).await?; let (fs, scheme) = { let res = resources.lock().await; let fs = res .require::()? .0 .clone(); let params = res .get::>() .cloned() .unwrap_or_default(); let scheme = params .0 .build_scheme() .map_err(kigi_tool_runtime::ToolError::invalid_arguments)?; (fs, scheme) }; match tokio::time::timeout( Duration::from_secs(DEFAULT_ANCHOR_TIMEOUT_SECS), inject_anchors(&result.stdout, &cwd, &*fs, &*scheme), ) .await { Ok(anchored) => result.stdout = anchored, Err(_elapsed) => { tracing::Span::current().record("timed_out", true); tracing::warn!( timeout_secs = DEFAULT_ANCHOR_TIMEOUT_SECS, "anchor injection timed out after {DEFAULT_ANCHOR_TIMEOUT_SECS}s, returning un-anchored results" ); let warning = format!( "\nHashline anchoring for tool output failed after \ {DEFAULT_ANCHOR_TIMEOUT_SECS} seconds. Since results lack \ anchoring, please read the file before editing.\n" ); result.stdout.extend_from_slice(warning.as_bytes()); } } } Ok(result) } } #[cfg(test)] mod tests { use super::*; fn test_scheme() -> Box { HashlineSchemeParams::default().build_scheme().unwrap() } #[test] fn parse_rg_line_match() { let (num, sep, content) = parse_rg_line("123: let x = 1;").unwrap(); assert_eq!(num, 123); assert_eq!(sep, ':'); assert_eq!(content, " let x = 1;"); } #[test] fn parse_rg_line_context() { let (num, sep, content) = parse_rg_line("45- let y = 2;").unwrap(); assert_eq!(num, 45); assert_eq!(sep, '-'); assert_eq!(content, " let y = 2;"); } #[test] fn parse_rg_line_no_digits() { assert!(parse_rg_line("src/main.rs").is_none()); } #[test] fn parse_rg_line_empty() { assert!(parse_rg_line("").is_none()); } #[test] fn tool_metadata() { use crate::types::tool_metadata::ToolMetadata; let tool = HashlineGrepTool; assert_eq!(kigi_tool_runtime::Tool::id(&tool).as_str(), "hashline_grep"); assert_eq!(ToolMetadata::kind(&tool), ToolKind::Search); assert!(kigi_tool_runtime::Tool::capabilities(&tool).is_read_only); assert!(matches!( ToolMetadata::tool_namespace(&tool), ToolNamespace::KigiHashline )); } #[test] fn description_mentions_anchors_and_edit() { use crate::types::tool_metadata::ToolMetadata; let tool = HashlineGrepTool; assert!(ToolMetadata::description_template(&tool).contains("anchor")); assert!(ToolMetadata::description_template(&tool).contains("tools.by_kind.edit")); } #[tokio::test] async fn inject_anchors_content_mode() { use crate::computer::local::LocalFs; use std::sync::Arc; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); std::fs::write( tmp.path().join("test.rs"), "fn main() {\n let x = 1;\n let y = 2;\n}\n", ) .unwrap(); let fs = Arc::new(LocalFs); let rg_output = format!( "\n\ Found 2 matching lines\n\ test.rs\n\ 2: let x = 1;\n\ 3: let y = 2;\n\ ", tmp.path().display() ); let scheme = test_scheme(); let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await; let output = String::from_utf8_lossy(&result); // Anchored lines should have the pattern NUM:LOCAL:CONTEXT:CONTENT // (3 colons for chunk scheme: line:local:context:content). for line in output.lines() { if line.starts_with(|c: char| c.is_ascii_digit()) { let colon_count = line.matches(':').count(); assert!( colon_count >= 3, "anchored line should have ≥3 colons, got {colon_count}: {line}" ); } } } #[tokio::test] async fn inject_anchors_preserves_headers() { use crate::computer::local::LocalFs; use std::sync::Arc; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("a.rs"), "hello\nworld\n").unwrap(); let fs = Arc::new(LocalFs); let rg_output = format!( "\n\ Found 1 matching lines\n\ a.rs\n\ 1:hello\n\ ", tmp.path().display() ); let scheme = test_scheme(); let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await; let output = String::from_utf8_lossy(&result); assert!(output.contains("Found 1 matching lines")); assert!(output.contains("a.rs\n")); assert!(output.contains("")); } #[tokio::test] async fn inject_anchors_context_lines() { use crate::computer::local::LocalFs; use std::sync::Arc; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("test.rs"), "a\nb\nc\nd\ne\n").unwrap(); let fs = Arc::new(LocalFs); let rg_output = format!( "\n\ Found 1 matching lines\n\ test.rs\n\ 2-b\n\ 3:c\n\ 4-d\n\ ", tmp.path().display() ); let scheme = test_scheme(); let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await; let output = String::from_utf8_lossy(&result); let line_2 = output.lines().find(|l| l.starts_with('2')).unwrap(); assert!( line_2.contains('-'), "context line should keep '-': {line_2}" ); let line_3 = output.lines().find(|l| l.starts_with('3')).unwrap(); // Count colons: line:local:context:content = 3 colons with ':' let colon_count = line_3.matches(':').count(); assert!( colon_count >= 3, "match line should have ≥3 colons: {line_3}" ); } #[tokio::test] async fn no_injection_for_missing_file() { use crate::computer::local::LocalFs; use std::sync::Arc; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let fs = Arc::new(LocalFs); let rg_output = format!( "\n\ Found 1 matching lines\n\ nonexistent.rs\n\ 5:some content\n\ ", tmp.path().display() ); let scheme = test_scheme(); let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await; let output = String::from_utf8_lossy(&result); assert!(output.contains("5:some content")); } #[tokio::test] async fn digit_leading_file_path_recognized_as_header() { use crate::computer::local::LocalFs; use std::sync::Arc; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let subdir = tmp.path().join("2024_data"); std::fs::create_dir_all(&subdir).unwrap(); std::fs::write(subdir.join("config.rs"), "let x = 1;\nlet y = 2;\n").unwrap(); let fs = Arc::new(LocalFs); let rg_output = format!( "\n\ Found 1 matching lines\n\ 2024_data/config.rs\n\ 1:let x = 1;\n\ ", tmp.path().display() ); let scheme = test_scheme(); let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await; let output = String::from_utf8_lossy(&result); // The file header should be recognized despite starting with digits. // Line 1 should be annotated with anchors from the correct file. let line_1 = output.lines().find(|l| l.starts_with('1')).unwrap(); let colon_count = line_1.matches(':').count(); assert!( colon_count >= 3, "anchors should be from the correct file, got: {line_1}" ); } #[tokio::test] async fn no_injection_for_files_with_matches_output() { use crate::computer::local::LocalFs; use std::sync::Arc; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let fs = Arc::new(LocalFs); // files_with_matches output has no numbered lines — just file paths. let rg_output = format!( "\n\ Found 2 files\n\ src/main.rs\n\ src/lib.rs\n\ ", tmp.path().display() ); let scheme = test_scheme(); let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await; let output = String::from_utf8_lossy(&result); assert!(output.contains("src/main.rs")); assert!(output.contains("src/lib.rs")); assert!( !output.contains('→'), "files_with_matches should have no anchors" ); } #[tokio::test] async fn no_injection_for_count_output() { use crate::computer::local::LocalFs; use std::sync::Arc; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let fs = Arc::new(LocalFs); let rg_output = format!( "\n\ src/main.rs:5\n\ src/lib.rs:3\n\ ", tmp.path().display() ); let scheme = test_scheme(); let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await; let output = String::from_utf8_lossy(&result); // Count lines should pass through. They look like "src/main.rs:5" // which parse_rg_line won't match (path contains '/', not just digits). assert!(output.contains("src/main.rs:5")); assert!(output.contains("src/lib.rs:3")); } #[tokio::test] async fn multiple_matches_same_file_use_cached_anchors() { use crate::computer::local::LocalFs; use std::sync::Arc; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); std::fs::write( tmp.path().join("multi.rs"), "fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n", ) .unwrap(); let fs = Arc::new(LocalFs); let rg_output = format!( "\n\ Found 4 matching lines\n\ multi.rs\n\ 1:fn a() {{}}\n\ 2:fn b() {{}}\n\ 3:fn c() {{}}\n\ 4:fn d() {{}}\n\ ", tmp.path().display() ); let scheme = test_scheme(); let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await; let output = String::from_utf8_lossy(&result); let anchored_count = output .lines() .filter(|l| l.starts_with(|c: char| c.is_ascii_digit())) .filter(|l| l.matches(':').count() >= 3) .count(); assert_eq!(anchored_count, 4, "all 4 lines should be anchored"); } #[tokio::test] async fn inject_anchors_timeout_returns_unanchored() { use crate::computer::local::LocalFs; use crate::computer::types::{AsyncFileSystem, ComputerError}; use std::sync::Arc; use tempfile::TempDir; struct SlowFs; #[async_trait::async_trait] impl AsyncFileSystem for SlowFs { async fn read_file(&self, _path: &Path) -> Result, ComputerError> { tokio::time::sleep(Duration::from_secs(10)).await; Ok(Vec::new()) } async fn write_file(&self, _path: &Path, _data: &[u8]) -> Result<(), ComputerError> { unimplemented!() } async fn delete_file(&self, _path: &Path) -> Result<(), ComputerError> { unimplemented!() } } let tmp = TempDir::new().unwrap(); std::fs::write( tmp.path().join("test.rs"), "fn main() {\n let x = 1;\n let y = 2;\n}\n", ) .unwrap(); let rg_output = format!( "\n\ Found 2 matching lines\n\ test.rs\n\ 2: let x = 1;\n\ 3: let y = 2;\n\ ", tmp.path().display() ); let rg_bytes = rg_output.as_bytes(); let scheme = test_scheme(); // Happy path: real filesystem produces anchored output. let fast_fs = Arc::new(LocalFs); let anchored = inject_anchors(rg_bytes, tmp.path(), &*fast_fs, &*scheme).await; let anchored_str = String::from_utf8_lossy(&anchored); for line in anchored_str.lines() { if line.starts_with(|c: char| c.is_ascii_digit()) { assert!( line.matches(':').count() >= 3, "happy path should produce anchored lines, got: {line}" ); } } // Timeout path: slow filesystem causes cancellation. let slow_fs = Arc::new(SlowFs); let timeout_result = tokio::time::timeout( Duration::from_millis(10), inject_anchors(rg_bytes, tmp.path(), &*slow_fs, &*scheme), ) .await; assert!( timeout_result.is_err(), "inject_anchors should have timed out" ); // On timeout, production keeps the original output unchanged. let unanchored_str = String::from_utf8_lossy(rg_bytes); for line in unanchored_str.lines() { if line.starts_with(|c: char| c.is_ascii_digit()) { assert_eq!( line.matches(':').count(), 1, "un-anchored line should have exactly 1 colon, got: {line}" ); } } assert!(unanchored_str.contains("Found 2 matching lines")); assert!(unanchored_str.contains("test.rs")); assert!(unanchored_str.contains("")); assert_ne!(anchored, rg_bytes, "anchored output should differ from raw"); } #[tokio::test] async fn truncated_output_stays_well_formed() { use crate::computer::local::LocalFs; use std::sync::Arc; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("big.rs"), "match\n".repeat(100)).unwrap(); let fs = Arc::new(LocalFs); let mut rg_lines = String::new(); for i in 1..=10 { rg_lines.push_str(&format!("{i}:match\n")); } let rg_output = format!( "\n\ Found at least 100 matching lines\n\ big.rs\n\ {rg_lines}\ ... [at least 90 lines truncated] ...\n\ ", tmp.path().display() ); let scheme = test_scheme(); let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await; let output = String::from_utf8_lossy(&result); assert!(output.contains("... [at least 90 lines truncated] ...")); assert!(output.contains("")); let anchored = output .lines() .filter(|l| l.starts_with(|c: char| c.is_ascii_digit())) .filter(|l| l.matches(':').count() >= 3) .count(); assert_eq!(anchored, 10); } }