use std::path::Path;
use regex_lite::Regex;
use serde_json::Value;
use super::{MAX_RESULTS, ToolDefinition, ToolOutput, ToolUseRequest, path};
use crate::tools::registry::{ToolContext, ToolError, ToolExecution};
use crate::utils;
const DEFAULT_RANGE_LINES: u32 = 40;
const NAME: &str = "sawk";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SawkInput {
action: String,
path: String,
start_line: Option<u64>,
end_line: Option<u64>,
max_lines: Option<u64>,
pattern: Option<String>,
replacement: Option<String>,
global: bool,
fields: Vec<u64>,
delimiter: Option<String>,
}
impl SawkInput {
fn from_value(args: &Value) -> Result<Self, ToolError> {
Ok(Self {
action: args
.get("action")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
path: args
.get("path")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
start_line: args.get("start_line").and_then(|value| value.as_u64()),
end_line: args.get("end_line").and_then(|value| value.as_u64()),
max_lines: args.get("max_lines").and_then(|value| value.as_u64()),
pattern: args.get("pattern").and_then(|value| value.as_str()).map(str::to_string),
replacement: args
.get("replacement")
.and_then(|value| value.as_str())
.map(str::to_string),
global: args.get("global").and_then(|value| value.as_bool()).unwrap_or(false),
fields: args
.get("fields")
.and_then(|value| value.as_array())
.map(|items| items.iter().filter_map(|value| value.as_u64()).collect())
.unwrap_or_default(),
delimiter: args
.get("delimiter")
.and_then(|value| value.as_str())
.map(str::to_string),
})
}
}
pub fn exec(args: &Value, root: &Path) -> ToolOutput {
match SawkInput::from_value(args) {
Ok(input) => exec_input(&input, root),
Err(error) => ToolOutput::failed(NAME, error.to_string()),
}
}
pub fn definition() -> ToolDefinition {
ToolDefinition::new(
NAME,
r#"sawk
Run safe read-only sed/awk-style inspection actions.
Use this for line printing, substitution previews, or field extraction when it is clearer
than raw shell. Actions are typed: sed_print, sed_substitute_preview, awk_fields.
Paths are contained; output is capped/truncated; no sed -i or awk system()."#,
serde_json::json!({
"type": "object",
"properties": {
"action": { "type": "string", "enum": ["sed_print", "sed_substitute_preview", "awk_fields"] },
"path": { "type": "string", "description": "Path relative to the workspace root." },
"start_line": { "type": "integer" },
"end_line": { "type": "integer" },
"max_lines": { "type": "integer" },
"pattern": { "type": "string", "description": "Regex pattern for preview/filter actions." },
"replacement": { "type": "string", "description": "Replacement text for sed_substitute_preview." },
"global": { "type": "boolean", "description": "Replace all matches per line in previews." },
"fields": { "type": "array", "items": { "type": "integer" }, "description": "1-indexed fields for awk_fields." },
"delimiter": { "type": "string", "description": "Optional literal field delimiter; defaults to whitespace." }
},
"required": ["action", "path"]
}),
)
}
pub fn execute_request(request: &ToolUseRequest, ctx: &ToolContext<'_>) -> ToolExecution {
let args = serde_json::from_str::<Value>(&request.arguments).unwrap_or(Value::Null);
ToolExecution::output(exec(&args, ctx.root))
}
fn exec_input(input: &SawkInput, root: &Path) -> ToolOutput {
let resolved = match path::resolve_within_root(root, &input.path) {
Ok(path) => path,
Err(e) => return ToolOutput::failed("sawk", e.to_string()),
};
let content = match std::fs::read_to_string(&resolved) {
Ok(content) => content,
Err(e) => return ToolOutput::failed("sawk", format!("read failed: {e}")),
};
match input.action.as_str() {
"sed_print" => sed_print(&content, input),
"sed_substitute_preview" => sed_substitute_preview(&content, input),
"awk_fields" => awk_fields(&content, input),
_ => ToolOutput::failed(
"sawk",
"missing or invalid action (expected sed_print, sed_substitute_preview, or awk_fields)".to_string(),
),
}
}
fn sed_print(content: &str, input: &SawkInput) -> ToolOutput {
let start = input.start_line.unwrap_or(1).max(1) as usize;
let end = input
.end_line
.map(|n| n.max(start as u64) as usize)
.unwrap_or(start + DEFAULT_RANGE_LINES as usize - 1);
let max_lines = max_lines(input);
let lines = content
.lines()
.enumerate()
.skip(start.saturating_sub(1))
.take(end.saturating_sub(start) + 1)
.take(max_lines)
.map(|(i, line)| format!("{}: {}", i + 1, utils::truncate_line(line)))
.collect::<Vec<_>>();
if lines.is_empty() {
return ToolOutput::failed("sawk", format!("no lines in range {start}-{end}"));
}
ToolOutput::ok("sawk", lines)
}
fn sed_substitute_preview(content: &str, input: &SawkInput) -> ToolOutput {
let pattern = input.pattern.as_deref().unwrap_or("");
if pattern.is_empty() {
return ToolOutput::failed("sawk", "sed_substitute_preview requires pattern".to_string());
}
let replacement = input.replacement.as_deref().unwrap_or("");
let regex = match Regex::new(pattern) {
Ok(regex) => regex,
Err(e) => return ToolOutput::failed("sawk", format!("invalid regex: {e}")),
};
let (start, end) = range(input);
let mut out = Vec::new();
for (i, line) in content.lines().enumerate() {
let line_no = i + 1;
if line_no < start || line_no > end {
continue;
}
let replaced = if input.global {
regex.replace_all(line, replacement).to_string()
} else {
regex.replace(line, replacement).to_string()
};
if replaced != line {
out.push(format!("-{}: {}", line_no, utils::truncate_line(line)));
out.push(format!("+{}: {}", line_no, utils::truncate_line(&replaced)));
if out.len() / 2 >= max_lines(input) {
break;
}
}
}
if out.is_empty() {
return ToolOutput::ok("sawk", vec!["no substitution matches".to_string()]);
}
ToolOutput::ok("sawk", out)
}
fn awk_fields(content: &str, input: &SawkInput) -> ToolOutput {
let fields = input
.fields
.iter()
.filter(|field| **field > 0)
.map(|field| *field as usize)
.collect::<Vec<_>>();
if fields.is_empty() {
return ToolOutput::failed("sawk", "awk_fields requires a non-empty fields array".to_string());
}
let filter = match input.pattern.as_deref() {
Some(pattern) if !pattern.is_empty() => match Regex::new(pattern) {
Ok(regex) => Some(regex),
Err(e) => return ToolOutput::failed("sawk", format!("invalid regex: {e}")),
},
_ => None,
};
let delimiter = input.delimiter.as_deref();
let (start, end) = range(input);
let mut out = Vec::new();
for (i, line) in content.lines().enumerate() {
let line_no = i + 1;
if line_no < start || line_no > end {
continue;
}
if filter.as_ref().is_some_and(|regex| !regex.is_match(line)) {
continue;
}
let parts = split_fields(line, delimiter);
let selected = fields
.iter()
.map(|field| parts.get(field - 1).copied().unwrap_or(""))
.collect::<Vec<_>>()
.join("\t");
out.push(format!("{}: {}", line_no, utils::truncate_line(&selected)));
if out.len() >= max_lines(input) {
break;
}
}
if out.is_empty() {
return ToolOutput::ok("sawk", vec!["no matching rows".to_string()]);
}
ToolOutput::ok("sawk", out)
}
fn split_fields<'a>(line: &'a str, delimiter: Option<&str>) -> Vec<&'a str> {
match delimiter {
Some(delimiter) if !delimiter.is_empty() => line.split(delimiter).collect(),
_ => line.split_whitespace().collect(),
}
}
fn range(input: &SawkInput) -> (usize, usize) {
let start = input.start_line.unwrap_or(1).max(1) as usize;
let end = input
.end_line
.map(|n| n.max(start as u64) as usize)
.unwrap_or(usize::MAX);
(start, end)
}
fn max_lines(input: &SawkInput) -> usize {
input
.max_lines
.map(|n| (n as usize).clamp(1, MAX_RESULTS))
.unwrap_or(MAX_RESULTS)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{app::ToolStatus, tools};
#[test]
fn sed_print_reads_range() {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(dir.path().join("file.txt"), "a\nb\nc\n").expect("write");
let args = serde_json::json!({"action":"sed_print","path":"file.txt","start_line":2,"end_line":3});
let output = exec(&args, dir.path());
assert_eq!(output.status, ToolStatus::Ok);
assert_eq!(output.display.lines, vec!["2: b", "3: c"]);
}
#[test]
fn sed_substitute_preview_is_read_only() {
let dir = tempfile::tempdir().expect("temp dir");
let file = dir.path().join("file.txt");
std::fs::write(&file, "foo\nbar foo\n").expect("write");
let args = serde_json::json!({
"action":"sed_substitute_preview",
"path":"file.txt",
"pattern":"foo",
"replacement":"baz",
"global":true
});
let output = exec(&args, dir.path());
assert_eq!(output.status, ToolStatus::Ok);
assert_eq!(
output.display.lines,
vec!["-1: foo", "+1: baz", "-2: bar foo", "+2: bar baz"]
);
assert_eq!(std::fs::read_to_string(&file).expect("read"), "foo\nbar foo\n");
}
#[test]
fn awk_fields_extracts_columns() {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(dir.path().join("file.txt"), "one two three\nred blue green\n").expect("write");
let args = serde_json::json!({"action":"awk_fields","path":"file.txt","fields":[2,3],"pattern":"red|one"});
let output = exec(&args, dir.path());
assert_eq!(output.status, ToolStatus::Ok);
assert_eq!(output.display.lines, vec!["1: two\tthree", "2: blue\tgreen"]);
}
#[test]
fn sawk_rejects_outside_root() {
let dir = tempfile::tempdir().expect("temp dir");
let outside = dir.path().parent().unwrap().join("escape.txt");
let args = serde_json::json!({"action":"sed_print","path":outside,"start_line":1});
let output = exec(&args, dir.path());
assert_eq!(output.status, ToolStatus::Failed);
assert!(
output
.error
.as_ref()
.is_some_and(|e| e.contains("escapes workspace root"))
);
}
#[test]
fn registry_execute_prints_range() {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(dir.path().join("file.txt"), "a\nb\nc\n").expect("write");
let request = ToolUseRequest::new(
NAME.to_string(),
serde_json::json!({"action":"sed_print","path":"file.txt","start_line":2,"end_line":3}).to_string(),
"call_1".to_string(),
);
let output = tools::registry::execute(&request, &tools::registry::ToolContext::new(dir.path())).output;
assert_eq!(output.status, ToolStatus::Ok);
assert_eq!(output.display.lines, vec!["2: b", "3: c"]);
}
}