use std::path::{Path, PathBuf};
use anyhow::Result;
use crate::entities::profile::ToolId;
use crate::entities::workspace::CommandSlot;
use super::reach::strip_verbatim;
use super::{Tool, ToolContext, ToolOutcome};
pub const CODE_LIST_ID: &str = "code_list";
pub const CODE_READ_ID: &str = "code_read";
pub const CODE_GREP_ID: &str = "code_grep";
pub const CODE_EDIT_ID: &str = "code_edit";
pub const CODE_WRITE_ID: &str = "code_write";
pub const CODE_BUILD_ID: &str = "code_build";
pub const CODE_RUN_ID: &str = "code_run";
pub const CODE_TEST_ID: &str = "code_test";
pub const WORKSPACE_TOOL_IDS: [&str; 8] = [
CODE_LIST_ID,
CODE_READ_ID,
CODE_GREP_ID,
CODE_EDIT_ID,
CODE_WRITE_ID,
CODE_BUILD_ID,
CODE_RUN_ID,
CODE_TEST_ID,
];
pub const ALL: [CodeTool; 8] = [
CodeTool::List,
CodeTool::Read,
CodeTool::Grep,
CodeTool::Edit,
CodeTool::Write,
CodeTool::Command(CommandSlot::Build),
CodeTool::Command(CommandSlot::Run),
CodeTool::Command(CommandSlot::Test),
];
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct WorkspaceCommands {
pub build: bool,
pub run: bool,
pub test: bool,
}
impl WorkspaceCommands {
pub fn of(ws: &crate::entities::workspace::Workspace) -> Self {
Self {
build: ws.command(CommandSlot::Build).is_some(),
run: ws.command(CommandSlot::Run).is_some(),
test: ws.command(CommandSlot::Test).is_some(),
}
}
fn has(self, slot: CommandSlot) -> bool {
match slot {
CommandSlot::Build => self.build,
CommandSlot::Run => self.run,
CommandSlot::Test => self.test,
}
}
}
pub fn is_workspace_tool(id: &str) -> bool {
WORKSPACE_TOOL_IDS.contains(&id)
}
pub fn offered(id: &str, attached: bool, commands: WorkspaceCommands) -> Option<bool> {
if !is_workspace_tool(id) {
return None;
}
Some(match CodeTool::from_id(id) {
Some(CodeTool::Command(slot)) => attached && commands.has(slot),
_ => attached,
})
}
const DEFAULT_READ_LINES: usize = 400;
const MAX_READ_CHARS: usize = 40_000;
const MAX_LIST_ENTRIES: usize = 400;
const DEFAULT_LIST_DEPTH: usize = 2;
const MAX_GREP_HITS: usize = 100;
const MAX_GREP_LINE: usize = 300;
const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
fn workspace_root(ctx: &ToolContext) -> Result<PathBuf> {
let Some(ws) = &ctx.workspace else {
anyhow::bail!(ctx.loc.t("tool.code.err.no_root").to_string());
};
let root = PathBuf::from(&ws.root);
let canonical = root
.canonicalize()
.map_err(|e| anyhow::anyhow!(format!("{}: {e}", ws.root)))?;
let root = strip_verbatim(&canonical);
super::reach::refuse_app_dirs(ctx, &root)?;
Ok(root)
}
fn resolve(ctx: &ToolContext, root: &Path, raw: &str) -> Result<PathBuf> {
let loc = ctx.loc;
let raw = raw.trim();
if raw.is_empty() {
anyhow::bail!(loc.t("tool.code.err.path_required").to_string());
}
let requested = PathBuf::from(raw);
let candidate = if requested.is_absolute() {
requested
} else {
root.join(&requested)
};
let canonical = match candidate.canonicalize() {
Ok(c) => strip_verbatim(&c),
Err(_) => {
let mut existing = candidate.as_path();
let mut tail: Vec<std::ffi::OsString> = Vec::new();
while !existing.exists() {
super::reach::refuse_dangling_link(existing, loc)?;
let name = existing.file_name().ok_or_else(|| {
anyhow::anyhow!(loc.t("tool.code.err.path_required").to_string())
})?;
tail.push(name.to_owned());
existing = existing.parent().ok_or_else(|| {
anyhow::anyhow!(loc.t("tool.code.err.path_required").to_string())
})?;
}
let mut resolved = strip_verbatim(
&existing
.canonicalize()
.map_err(|e| anyhow::anyhow!(format!("{}: {e}", existing.display())))?,
);
for part in tail.iter().rev() {
resolved.push(part);
}
resolved
}
};
if !canonical.starts_with(root) {
anyhow::bail!(loc.tf(
"tool.code.err.outside",
&[("root", &root.display().to_string())]
));
}
super::reach::refuse_app_dirs(ctx, &canonical)?;
Ok(canonical)
}
fn refuse_git_internals(ctx: &ToolContext, root: &Path, path: &Path) -> Result<()> {
let inside = path.strip_prefix(root).unwrap_or(path);
if inside
.components()
.any(|c| c.as_os_str().eq_ignore_ascii_case(".git"))
{
anyhow::bail!(ctx.loc.t("tool.code.err.git_dir").to_string());
}
Ok(())
}
fn display_rel(path: &Path, root: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string()
.replace('\\', "/")
}
pub(crate) struct TextFile {
pub text: String,
pub crlf: bool,
pub bom: bool,
pub encoding: &'static encoding_rs::Encoding,
}
impl TextFile {
pub fn load(bytes: &[u8], markup: bool, hint: Option<&str>) -> Option<Self> {
let file = crate::shared::text_decode::decode_file(bytes, markup, hint)?;
let crlf = file.text.contains("\r\n");
Some(Self {
text: file.text.replace("\r\n", "\n"),
crlf,
bom: file.bom,
encoding: file.encoding,
})
}
pub fn round_trips(&self, bytes: &[u8]) -> bool {
let body = if self.bom {
&bytes[crate::shared::text_decode::bom_of(self.encoding).len()..]
} else {
bytes
};
crate::shared::text_decode::round_trips(body, self.encoding)
}
pub fn encode(&self, text: &str) -> Result<Vec<u8>, char> {
let body = if self.crlf {
text.replace('\n', "\r\n")
} else {
text.to_string()
};
let mut out = if self.bom {
crate::shared::text_decode::bom_of(self.encoding).to_vec()
} else {
Vec::new()
};
out.extend(crate::shared::text_decode::encode(&body, self.encoding)?);
Ok(out)
}
}
fn arg_str(args: &serde_json::Value, key: &str) -> Option<String> {
args.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn arg_usize(args: &serde_json::Value, key: &str) -> Option<usize> {
args.get(key).and_then(|v| {
v.as_u64()
.map(|n| n as usize)
.or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))
})
}
async fn read_text(
path: &Path,
loc: &crate::shared::i18n::Locale,
hint: Option<&str>,
) -> Result<TextFile> {
let meta = tokio::fs::metadata(path)
.await
.map_err(|e| anyhow::anyhow!(format!("{}: {e}", path.display())))?;
if meta.len() > MAX_FILE_BYTES {
anyhow::bail!(loc.tf(
"tool.code.err.too_large",
&[("max", &(MAX_FILE_BYTES / 1024).to_string())]
));
}
let bytes = tokio::fs::read(path)
.await
.map_err(|e| anyhow::anyhow!(format!("{}: {e}", path.display())))?;
TextFile::load(
&bytes,
crate::shared::text_decode::is_markup_path(path),
hint,
)
.ok_or_else(|| anyhow::anyhow!(loc.t("tool.code.err.binary").to_string()))
}
fn numbered(lines: &[&str], from: usize, to: usize) -> String {
let mut out = String::new();
for (i, line) in lines[from..to].iter().enumerate() {
out.push_str(&format!("{:>6}\u{2192}{line}\n", from + i + 1));
}
out
}
fn walker(
dir: &Path,
max_depth: Option<usize>,
overrides: Option<ignore::overrides::Override>,
skip: Vec<PathBuf>,
) -> ignore::Walk {
let mut builder = ignore::WalkBuilder::new(dir);
builder.filter_entry(move |entry| !skip.iter().any(|d| entry.path().starts_with(d)));
builder
.hidden(true)
.git_ignore(true)
.git_exclude(true)
.parents(true)
.require_git(false)
.follow_links(false)
.max_depth(max_depth);
if let Some(ov) = overrides {
builder.overrides(ov);
}
builder.build()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodeTool {
List,
Read,
Grep,
Edit,
Write,
Command(CommandSlot),
}
impl CodeTool {
pub fn id(self) -> &'static str {
match self {
Self::List => CODE_LIST_ID,
Self::Read => CODE_READ_ID,
Self::Grep => CODE_GREP_ID,
Self::Edit => CODE_EDIT_ID,
Self::Write => CODE_WRITE_ID,
Self::Command(CommandSlot::Build) => CODE_BUILD_ID,
Self::Command(CommandSlot::Run) => CODE_RUN_ID,
Self::Command(CommandSlot::Test) => CODE_TEST_ID,
}
}
pub fn from_id(id: &str) -> Option<Self> {
ALL.into_iter().find(|t| t.id() == id)
}
fn label(self) -> &'static str {
match self {
Self::List => "list project files",
Self::Read => "read project file",
Self::Grep => "search project",
Self::Edit => "edit project file",
Self::Write => "write project file",
Self::Command(CommandSlot::Build) => "build the project",
Self::Command(CommandSlot::Run) => "run the project",
Self::Command(CommandSlot::Test) => "test the project",
}
}
fn description_key(self) -> &'static str {
match self {
Self::List => "tool.code_list.desc",
Self::Read => "tool.code_read.desc",
Self::Grep => "tool.code_grep.desc",
Self::Edit => "tool.code_edit.desc",
Self::Write => "tool.code_write.desc",
Self::Command(CommandSlot::Build) => "tool.code_build.desc",
Self::Command(CommandSlot::Run) => "tool.code_run.desc",
Self::Command(CommandSlot::Test) => "tool.code_test.desc",
}
}
pub fn gloss_key(self) -> &'static str {
match self {
Self::List => "prompt.workspace.tool.list",
Self::Read => "prompt.workspace.tool.read",
Self::Grep => "prompt.workspace.tool.grep",
Self::Edit => "prompt.workspace.tool.edit",
Self::Write => "prompt.workspace.tool.write",
Self::Command(CommandSlot::Build) => "prompt.workspace.tool.build",
Self::Command(CommandSlot::Run) => "prompt.workspace.tool.run",
Self::Command(CommandSlot::Test) => "prompt.workspace.tool.test",
}
}
pub fn slot(self) -> Option<CommandSlot> {
match self {
Self::Command(slot) => Some(slot),
_ => None,
}
}
fn changes_files(self) -> bool {
matches!(self, Self::Edit | Self::Write | Self::Command(_))
}
}
#[async_trait::async_trait]
impl Tool for CodeTool {
fn id(&self) -> ToolId {
CodeTool::id(*self).into()
}
fn group(&self) -> super::meta::ToolGroup {
super::meta::ToolGroup::Files
}
fn ui_label(&self) -> &'static str {
self.label()
}
fn danger(&self) -> bool {
self.changes_files()
}
fn concurrent(&self) -> bool {
matches!(self, Self::List | Self::Read | Self::Grep)
}
fn counts_toward_round_limit(&self) -> bool {
false
}
fn description(&self, loc: &crate::shared::i18n::Locale) -> String {
loc.t(self.description_key()).into()
}
fn parameters(&self, loc: &crate::shared::i18n::Locale) -> serde_json::Value {
match self {
Self::List => serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": loc.t("tool.code.param.dir")},
"depth": {"type": "integer", "description": loc.t("tool.code.param.depth")}
}
}),
Self::Read => serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": loc.t("tool.code.param.path")},
"offset": {"type": "integer", "description": loc.t("tool.code.param.offset")},
"limit": {"type": "integer", "description": loc.t("tool.code.param.limit")}
},
"required": ["path"]
}),
Self::Grep => serde_json::json!({
"type": "object",
"properties": {
"pattern": {"type": "string", "description": loc.t("tool.code.param.pattern")},
"path": {"type": "string", "description": loc.t("tool.code.param.grep_dir")},
"glob": {"type": "string", "description": loc.t("tool.code.param.glob")}
},
"required": ["pattern"]
}),
Self::Edit => serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": loc.t("tool.code.param.path")},
"old_string": {"type": "string", "description": loc.t("tool.code.param.old_string")},
"new_string": {"type": "string", "description": loc.t("tool.code.param.new_string")},
"replace_all": {"type": "boolean", "description": loc.t("tool.code.param.replace_all")}
},
"required": ["path", "old_string", "new_string"]
}),
Self::Write => serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": loc.t("tool.code.param.path")},
"content": {"type": "string", "description": loc.t("tool.code.param.content")}
},
"required": ["path", "content"]
}),
Self::Command(_) => serde_json::json!({"type": "object", "properties": {}}),
}
}
async fn invoke(&self, ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
match self {
Self::List => list(ctx, args).await,
Self::Read => read(ctx, args).await,
Self::Grep => grep(ctx, args).await,
Self::Edit => edit(ctx, args).await,
Self::Write => write(ctx, args).await,
Self::Command(slot) => run_command(ctx, *slot).await,
}
}
}
fn collect_entries(base: &Path, depth: usize, skip: Vec<PathBuf>) -> (Vec<String>, bool) {
let mut entries = Vec::new();
let mut truncated = false;
for entry in walker(base, Some(depth), None, skip).flatten() {
if entry.depth() == 0 {
continue; }
if entries.len() >= MAX_LIST_ENTRIES {
truncated = true;
break;
}
let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
let rel = display_rel(entry.path(), base);
entries.push(if is_dir { format!("{rel}/") } else { rel });
}
entries.sort();
(entries, truncated)
}
async fn list(ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
let root = workspace_root(ctx)?;
let dir = match arg_str(&args, "path").filter(|s| !s.trim().is_empty()) {
Some(raw) => resolve(ctx, &root, &raw)?,
None => root.clone(),
};
let depth = arg_usize(&args, "depth")
.unwrap_or(DEFAULT_LIST_DEPTH)
.clamp(1, 16);
let base = dir.clone();
let skip = super::reach::app_dirs(ctx);
let (entries, truncated) =
tokio::task::spawn_blocking(move || collect_entries(&base, depth, skip)).await?;
let shown = display_rel(&dir, &root);
let shown = if shown.is_empty() {
".".to_string()
} else {
shown
};
if entries.is_empty() {
return Ok(ToolOutcome::text(
ctx.loc.tf("tool.code.list.empty", &[("path", &shown)]),
));
}
let mut out = ctx.loc.tf(
"tool.code.list.header",
&[("path", &shown), ("n", &entries.len().to_string())],
);
out.push('\n');
out.push_str(&entries.join("\n"));
if truncated {
out.push('\n');
out.push_str(&ctx.loc.tf(
"tool.code.list.truncated",
&[("max", &MAX_LIST_ENTRIES.to_string())],
));
}
Ok(ToolOutcome::text(out))
}
async fn read(ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
let root = workspace_root(ctx)?;
let raw = arg_str(&args, "path")
.ok_or_else(|| anyhow::anyhow!(ctx.loc.t("tool.code.err.path_required").to_string()))?;
let path = resolve(ctx, &root, &raw)?;
let file = read_text(&path, ctx.loc, ctx.file_hint).await?;
let lines: Vec<&str> = file.text.lines().collect();
let total = lines.len();
let offset = arg_usize(&args, "offset").unwrap_or(1).max(1);
let limit = arg_usize(&args, "limit")
.unwrap_or(DEFAULT_READ_LINES)
.max(1);
let start = offset - 1;
if start >= total && total > 0 {
anyhow::bail!(
ctx.loc
.tf("tool.code.err.bad_offset", &[("total", &total.to_string())])
);
}
let end = (start + limit).min(total);
let mut body = numbered(&lines, start, end);
let clipped = body.chars().count() > MAX_READ_CHARS;
if clipped {
body = body.chars().take(MAX_READ_CHARS).collect();
}
let (rel, from, to, all) = (
display_rel(&path, &root),
(start + 1).to_string(),
end.to_string(),
total.to_string(),
);
let mut out = if file.encoding == encoding_rs::UTF_8 {
ctx.loc.tf(
"tool.code.read.header",
&[
("path", &rel),
("from", &from),
("to", &to),
("total", &all),
],
)
} else {
ctx.loc.tf(
"tool.code.read.header_encoding",
&[
("path", &rel),
("encoding", file.encoding.name()),
("from", &from),
("to", &to),
("total", &all),
],
)
};
out.push('\n');
out.push_str(&body);
if end < total || clipped {
out.push_str(
&ctx.loc
.tf("tool.code.read.more", &[("next", &(end + 1).to_string())]),
);
}
Ok(ToolOutcome::text(out))
}
fn glob_override(root: &Path, glob: &str) -> Result<ignore::overrides::Override, ignore::Error> {
let mut builder = ignore::overrides::OverrideBuilder::new(root);
builder.add(glob)?;
builder.build()
}
fn searchable(entry: &ignore::DirEntry, hint: Option<&str>) -> Option<TextFile> {
if !entry.file_type().is_some_and(|t| t.is_file()) {
return None;
}
let path = entry.path();
if path.metadata().ok()?.len() > MAX_FILE_BYTES {
return None;
}
let bytes = std::fs::read(path).ok()?;
TextFile::load(
&bytes,
crate::shared::text_decode::is_markup_path(path),
hint,
)
}
fn clip_hit(text: &str) -> String {
if text.chars().count() > MAX_GREP_LINE {
text.chars().take(MAX_GREP_LINE).collect::<String>() + "…"
} else {
text.to_string()
}
}
fn scan_text(text: &str, re: ®ex::Regex, prefix: &str, hits: &mut Vec<String>) -> bool {
for (i, line) in text.lines().enumerate() {
if !re.is_match(line) {
continue;
}
if hits.len() >= MAX_GREP_HITS {
return true;
}
let text = clip_hit(line.trim_end());
hits.push(format!("{prefix}:{}: {text}", i + 1));
}
false
}
fn search_files(
dir: &Path,
overrides: Option<ignore::overrides::Override>,
re: ®ex::Regex,
root: &Path,
hint: Option<&str>,
skip: Vec<PathBuf>,
) -> (Vec<String>, bool, usize) {
let mut hits: Vec<String> = Vec::new();
let mut truncated = false;
let mut files = 0usize;
for entry in walker(dir, None, overrides, skip).flatten() {
let Some(file) = searchable(&entry, hint) else {
continue;
};
files += 1;
let prefix = display_rel(entry.path(), root);
if scan_text(&file.text, re, &prefix, &mut hits) {
truncated = true;
break;
}
}
(hits, truncated, files)
}
async fn grep(ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
let root = workspace_root(ctx)?;
let pattern = arg_str(&args, "pattern")
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!(ctx.loc.t("tool.code.err.pattern_required").to_string()))?;
let dir = match arg_str(&args, "path").filter(|s| !s.trim().is_empty()) {
Some(raw) => resolve(ctx, &root, &raw)?,
None => root.clone(),
};
let smart_case = !pattern.chars().any(char::is_uppercase);
let re = match regex::RegexBuilder::new(&pattern)
.case_insensitive(smart_case)
.build()
{
Ok(re) => re,
Err(err) => {
return Ok(ToolOutcome::text(ctx.loc.tf(
"tool.code.grep.bad_pattern",
&[("pattern", &pattern), ("err", &err.to_string())],
)));
}
};
let glob = arg_str(&args, "glob").filter(|s| !s.trim().is_empty());
let overrides = match glob.as_deref().map(|g| glob_override(&root, g)).transpose() {
Ok(ov) => ov,
Err(err) => {
return Ok(ToolOutcome::text(ctx.loc.tf(
"tool.code.grep.bad_glob",
&[
("glob", glob.as_deref().unwrap_or_default()),
("err", &err.to_string()),
],
)));
}
};
let root_for_walk = root.clone();
let hint = ctx.file_hint;
let skip = super::reach::app_dirs(ctx);
let (hits, truncated, files) = tokio::task::spawn_blocking(move || {
search_files(&dir, overrides, &re, &root_for_walk, hint, skip)
})
.await?;
if hits.is_empty() {
let key = if files == 0 {
"tool.code.grep.nothing_searched"
} else {
"tool.code.grep.empty"
};
return Ok(ToolOutcome::text(ctx.loc.tf(
key,
&[
("pattern", &pattern),
("glob", glob.as_deref().unwrap_or("*")),
],
)));
}
let mut out = ctx.loc.tf(
"tool.code.grep.header",
&[("pattern", &pattern), ("n", &hits.len().to_string())],
);
out.push('\n');
out.push_str(&hits.join("\n"));
if truncated {
out.push('\n');
out.push_str(&ctx.loc.tf(
"tool.code.grep.truncated",
&[("max", &MAX_GREP_HITS.to_string())],
));
}
Ok(ToolOutcome::text(out))
}
async fn journal_before_write(
ctx: &ToolContext,
root: &Path,
path: &Path,
existing: Option<&[u8]>,
) -> Result<()> {
let Some(dir) = &ctx.workspace_journal else {
anyhow::bail!(ctx.loc.t("tool.code.err.no_journal").to_string());
};
let rel = display_rel(path, root);
let root = root.display().to_string();
let bytes = existing.map(<[u8]>::to_vec);
let dir = dir.clone();
tokio::task::spawn_blocking(move || {
let journal = crate::features::workspace_journal::Journal::new(dir);
journal.record(&root, &rel, bytes.as_deref())
})
.await?
.map_err(|err| {
anyhow::anyhow!(
ctx.loc
.tf("tool.code.err.journal_failed", &[("err", &err.to_string())])
)
})?;
Ok(())
}
fn unmappable(
ctx: &ToolContext,
rel: &str,
encoding: &'static encoding_rs::Encoding,
c: char,
) -> ToolOutcome {
ToolOutcome::text(ctx.loc.tf(
"tool.code.edit.unmappable",
&[
("path", rel),
("encoding", encoding.name()),
("char", &c.to_string()),
],
))
}
async fn edit(ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
let root = workspace_root(ctx)?;
let raw = arg_str(&args, "path")
.ok_or_else(|| anyhow::anyhow!(ctx.loc.t("tool.code.err.path_required").to_string()))?;
let path = resolve(ctx, &root, &raw)?;
refuse_git_internals(ctx, &root, &path)?;
let old = arg_str(&args, "old_string")
.ok_or_else(|| anyhow::anyhow!(ctx.loc.t("tool.code.err.edit_args").to_string()))?;
let new = arg_str(&args, "new_string")
.ok_or_else(|| anyhow::anyhow!(ctx.loc.t("tool.code.err.edit_args").to_string()))?;
if old.is_empty() {
anyhow::bail!(ctx.loc.t("tool.code.err.edit_args").to_string());
}
let replace_all = args
.get("replace_all")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let bytes = tokio::fs::read(&path)
.await
.map_err(|e| anyhow::anyhow!(format!("{}: {e}", path.display())))?;
let markup = crate::shared::text_decode::is_markup_path(&path);
let file = TextFile::load(&bytes, markup, ctx.file_hint)
.ok_or_else(|| anyhow::anyhow!(ctx.loc.t("tool.code.err.binary").to_string()))?;
let old_n = old.replace("\r\n", "\n");
let new_n = new.replace("\r\n", "\n");
let count = file.text.matches(&old_n).count();
let rel = display_rel(&path, &root);
if count == 0 {
return Ok(ToolOutcome::text(
ctx.loc.tf("tool.code.edit.not_found", &[("path", &rel)]),
));
}
if count > 1 && !replace_all {
return Ok(ToolOutcome::text(ctx.loc.tf(
"tool.code.edit.ambiguous",
&[("n", &count.to_string()), ("path", &rel)],
)));
}
if !file.round_trips(&bytes) {
return Ok(ToolOutcome::text(ctx.loc.tf(
"tool.code.edit.not_round_trip",
&[("path", &rel), ("encoding", file.encoding.name())],
)));
}
let updated = if replace_all {
file.text.replace(&old_n, &new_n)
} else {
file.text.replacen(&old_n, &new_n, 1)
};
let encoded = match file.encode(&updated) {
Ok(encoded) => encoded,
Err(c) => return Ok(unmappable(ctx, &rel, file.encoding, c)),
};
journal_before_write(ctx, &root, &path, Some(&bytes)).await?;
tokio::fs::write(&path, encoded)
.await
.map_err(|e| anyhow::anyhow!(format!("{}: {e}", path.display())))?;
let at = updated
.find(&new_n)
.map(|byte| updated[..byte].matches('\n').count())
.unwrap_or(0);
let lines: Vec<&str> = updated.lines().collect();
let from = at.saturating_sub(3);
let to = (at + new_n.lines().count() + 3).min(lines.len());
let applied = if replace_all { count } else { 1 };
let mut out = ctx.loc.tf(
"tool.code.edit.ok",
&[("path", &rel), ("n", &applied.to_string())],
);
out.push('\n');
out.push_str(&numbered(&lines, from, to));
Ok(ToolOutcome::text(out))
}
async fn write(ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
let root = workspace_root(ctx)?;
let raw = arg_str(&args, "path")
.ok_or_else(|| anyhow::anyhow!(ctx.loc.t("tool.code.err.path_required").to_string()))?;
let path = resolve(ctx, &root, &raw)?;
refuse_git_internals(ctx, &root, &path)?;
let content = arg_str(&args, "content")
.ok_or_else(|| anyhow::anyhow!(ctx.loc.t("tool.code.err.write_args").to_string()))?;
let existing = tokio::fs::read(&path).await.ok();
let content_n = content.replace("\r\n", "\n");
let rel = display_rel(&path, &root);
let markup = crate::shared::text_decode::is_markup_path(&path);
let loaded = existing
.as_deref()
.and_then(|b| TextFile::load(b, markup, ctx.file_hint).map(|f| (f.round_trips(b), f)));
let shaped = match loaded {
Some((true, file)) => match file.encode(&content_n) {
Ok(encoded) => encoded,
Err(c) => return Ok(unmappable(ctx, &rel, file.encoding, c)),
},
Some((false, file)) => TextFile {
encoding: encoding_rs::UTF_8,
..file
}
.encode(&content_n)
.unwrap_or_else(|_| content_n.clone().into_bytes()),
None => content_n.into_bytes(),
};
journal_before_write(ctx, &root, &path, existing.as_deref()).await?;
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| anyhow::anyhow!(format!("{}: {e}", parent.display())))?;
}
tokio::fs::write(&path, &shaped)
.await
.map_err(|e| anyhow::anyhow!(format!("{}: {e}", path.display())))?;
let key = if existing.is_some() {
"tool.code.write.replaced"
} else {
"tool.code.write.created"
};
Ok(ToolOutcome::text(ctx.loc.tf(
key,
&[("path", &rel), ("n", &content.lines().count().to_string())],
)))
}
static COMMAND_GATE: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1);
enum Ended {
Exited(std::process::ExitStatus),
TimedOut,
Cancelled,
}
async fn run_command(ctx: &ToolContext, slot: CommandSlot) -> Result<ToolOutcome> {
let root = workspace_root(ctx)?;
let ws = ctx
.workspace
.as_ref()
.ok_or_else(|| anyhow::anyhow!(ctx.loc.t("tool.code.err.no_root").to_string()))?;
let Some(line) = ws.command(slot) else {
anyhow::bail!(ctx.loc.tf(
"tool.code.cmd.not_set",
&[("slot", slot.key()), ("cmd", &slot.setter_command())]
));
};
if let Some(ch) = crate::shared::cmdline::shell_syntax(line) {
anyhow::bail!(ctx.loc.tf(
"tool.code.cmd.shell",
&[("char", &ch.to_string()), ("line", line)]
));
}
let argv = crate::shared::cmdline::split(line);
let Some((program, args)) = argv.split_first() else {
anyhow::bail!(ctx.loc.tf(
"tool.code.cmd.not_set",
&[("slot", slot.key()), ("cmd", &slot.setter_command())]
));
};
let Ok(_permit) = COMMAND_GATE.try_acquire() else {
anyhow::bail!(ctx.loc.t("tool.code.cmd.busy").to_string());
};
let cfg = ctx.workspace_cfg;
let timeout = std::time::Duration::from_secs(cfg.command_timeout_secs.max(1));
let started = std::time::Instant::now();
let (ended, stdout, stderr) = spawn_and_wait(
program,
args,
&root,
timeout,
&ctx.cancel,
&ctx.named_secrets,
)
.await?;
let secs = format!("{:.1}", started.elapsed().as_secs_f64());
let status = match &ended {
Ended::Exited(_) => ctx.loc.tf("tool.code.cmd.finished", &[("secs", &secs)]),
Ended::TimedOut => ctx.loc.tf(
"tool.code.cmd.timed_out",
&[("secs", &cfg.command_timeout_secs.to_string())],
),
Ended::Cancelled => ctx.loc.t("tool.code.cmd.cancelled").to_string(),
};
let header = format!("{line}\n{status}");
let limit = cfg.output_limit_chars.max(200);
let (success, code) = match &ended {
Ended::Exited(st) => (st.success(), st.code()),
Ended::TimedOut | Ended::Cancelled => (true, None),
};
Ok(ToolOutcome::text(super::present::format_console(
Some(&header),
&clip(&strip_ansi(&stdout), limit, ctx.loc),
&clip(&strip_ansi(&stderr), limit, ctx.loc),
success,
code,
ctx.loc,
)))
}
async fn spawn_and_wait(
program: &str,
args: &[String],
root: &Path,
timeout: std::time::Duration,
cancel: &tokio_util::sync::CancellationToken,
named_secrets: &[String],
) -> Result<(Ended, String, String)> {
use tokio::io::AsyncReadExt;
let resolved = crate::shared::mcp::resolve_command(program);
let mut cmd = match &resolved {
Some(path) => tokio::process::Command::new(path),
None => tokio::process::Command::new(program),
};
cmd.args(args)
.current_dir(root)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.env("NO_COLOR", "1")
.env("CLICOLOR", "0")
.kill_on_drop(true);
for name in crate::shared::child_env::credential_vars(named_secrets) {
cmd.env_remove(name);
}
#[cfg(windows)]
{
cmd.creation_flags(0x0800_0000);
}
crate::shared::proc::prepare_group(&mut cmd);
let mut child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!(format!("{program}: {e}")))?;
let mut guard = crate::shared::proc::TreeGuard::assign_group(&child);
let mut out_pipe = child.stdout.take().expect("stdout piped");
let mut err_pipe = child.stderr.take().expect("stderr piped");
let out_task = tokio::spawn(async move {
let mut buf = Vec::new();
let _ = out_pipe.read_to_end(&mut buf).await;
buf
});
let err_task = tokio::spawn(async move {
let mut buf = Vec::new();
let _ = err_pipe.read_to_end(&mut buf).await;
buf
});
let ended = tokio::select! {
status = child.wait() => match status {
Ok(st) => Ended::Exited(st),
Err(e) => return Err(anyhow::anyhow!(format!("{program}: {e}"))),
},
_ = tokio::time::sleep(timeout) => Ended::TimedOut,
_ = cancel.cancelled() => Ended::Cancelled,
};
if !matches!(ended, Ended::Exited(_)) {
guard.kill();
let _ = child.start_kill();
}
let _ = child.wait().await;
guard.disarm();
let stdout = out_task.await.unwrap_or_default();
let stderr = err_task.await.unwrap_or_default();
Ok((
ended,
String::from_utf8_lossy(&stdout).into_owned(),
String::from_utf8_lossy(&stderr).into_owned(),
))
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c != '\u{1b}' {
out.push(c);
continue;
}
match chars.peek() {
Some('[') => {
chars.next();
skip_csi(&mut chars);
}
Some(']') => {
chars.next();
skip_osc(&mut chars);
}
_ => {
chars.next();
}
}
}
out
}
fn skip_csi(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
for c in chars.by_ref() {
if ('@'..='~').contains(&c) {
break;
}
}
}
fn skip_osc(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
while let Some(c) = chars.next() {
if c == '\u{7}' {
break;
}
if c == '\u{1b}' && chars.peek() == Some(&'\\') {
chars.next();
break;
}
}
}
fn clip(s: &str, max: usize, loc: &crate::shared::i18n::Locale) -> String {
let total = s.chars().count();
if total <= max {
return s.to_string();
}
let head_len = max / 2;
let tail_len = max - head_len;
let head: String = s.chars().take(head_len).collect();
let tail: String = s.chars().skip(total - tail_len).collect();
let note = loc.tf(
"tool.code.cmd.truncated",
&[("n", &(total - max).to_string())],
);
format!("{head}\n{note}\n{tail}")
}
#[cfg(test)]
mod tests {
use super::super::testkit::ctx_with_storage;
use super::*;
use crate::entities::workspace::Workspace;
use uuid::Uuid;
struct Fixture {
_data: tempfile::TempDir,
dir: tempfile::TempDir,
ctx: ToolContext,
}
fn fixture(files: &[(&str, &str)]) -> Fixture {
let dir = tempfile::tempdir().unwrap();
for (name, body) in files {
let path = dir.path().join(name);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, body).unwrap();
}
let (data, _storage, mut ctx) = ctx_with_storage(Uuid::new_v4());
ctx.workspace = Some(Workspace::new(dir.path().to_string_lossy().into_owned()));
Fixture {
_data: data,
dir,
ctx,
}
}
fn detached() -> Fixture {
let f = fixture(&[]);
Fixture {
ctx: ToolContext {
workspace: None,
..f.ctx
},
..f
}
}
#[tokio::test]
async fn read_numbers_lines_and_reports_total() {
let f = fixture(&[("a.rs", "one\ntwo\nthree\n")]);
let out = CodeTool::Read
.invoke(&f.ctx, serde_json::json!({"path": "a.rs"}))
.await
.unwrap();
assert!(out.result.contains("\u{2192}two"), "got: {}", out.result);
assert!(
out.result.contains('3'),
"the total must be stated: {}",
out.result
);
}
#[tokio::test]
async fn read_windows_a_long_file_and_says_where_to_continue() {
let body: String = (1..=50).map(|i| format!("line {i}\n")).collect();
let f = fixture(&[("big.rs", body.as_str())]);
let first = CodeTool::Read
.invoke(&f.ctx, serde_json::json!({"path": "big.rs", "limit": 10}))
.await
.unwrap();
assert!(first.result.contains("\u{2192}line 10"), "{}", first.result);
assert!(
!first.result.contains("\u{2192}line 11"),
"{}",
first.result
);
assert!(first.result.contains("offset=11"), "{}", first.result);
let second = CodeTool::Read
.invoke(
&f.ctx,
serde_json::json!({"path": "big.rs", "offset": 11, "limit": 10}),
)
.await
.unwrap();
assert!(
second.result.contains(" 11\u{2192}line 11"),
"{}",
second.result
);
}
#[tokio::test]
async fn grep_locates_matches_and_takes_a_regex() {
let f = fixture(&[
("src/a.rs", "fn mean() {}\nfn median() {}\n"),
("src/b.rs", "// nothing here\n"),
]);
let out = CodeTool::Grep
.invoke(&f.ctx, serde_json::json!({"pattern": r"fn me(an|dian)"}))
.await
.unwrap();
assert!(out.result.contains("src/a.rs:1"), "got: {}", out.result);
assert!(out.result.contains("src/a.rs:2"), "got: {}", out.result);
assert!(!out.result.contains("b.rs"), "got: {}", out.result);
}
#[tokio::test]
async fn grep_is_case_insensitive_until_the_pattern_has_a_capital() {
let f = fixture(&[("a.rs", "struct Widget;\n")]);
let lower = CodeTool::Grep
.invoke(&f.ctx, serde_json::json!({"pattern": "widget"}))
.await
.unwrap();
assert!(lower.result.contains("a.rs:1"), "got: {}", lower.result);
let upper = CodeTool::Grep
.invoke(&f.ctx, serde_json::json!({"pattern": "WIDGET"}))
.await
.unwrap();
assert!(!upper.result.contains("a.rs:1"), "got: {}", upper.result);
}
#[tokio::test]
async fn grep_names_a_broken_pattern() {
let f = fixture(&[("a.rs", "x\n")]);
let out = CodeTool::Grep
.invoke(&f.ctx, serde_json::json!({"pattern": "fn ("}))
.await
.unwrap();
assert!(out.result.contains("fn ("), "got: {}", out.result);
}
#[tokio::test]
async fn grep_filters_by_glob() {
let f = fixture(&[("src/a.rs", "target\n"), ("notes.md", "target\n")]);
let out = CodeTool::Grep
.invoke(
&f.ctx,
serde_json::json!({"pattern": "target", "glob": "**/*.rs"}),
)
.await
.unwrap();
assert!(out.result.contains("src/a.rs"), "got: {}", out.result);
assert!(!out.result.contains("notes.md"), "got: {}", out.result);
}
#[tokio::test]
async fn an_empty_search_says_which_kind_of_empty_it_was() {
let f = fixture(&[("a.rs", "needle\n")]);
let no_hits = CodeTool::Grep
.invoke(&f.ctx, serde_json::json!({"pattern": "absent"}))
.await
.unwrap();
let no_files = CodeTool::Grep
.invoke(
&f.ctx,
serde_json::json!({"pattern": "needle", "glob": "**/*.py"}),
)
.await
.unwrap();
assert_ne!(
no_hits.result, no_files.result,
"the two empties must be told apart"
);
assert!(no_files.result.contains("*.py"), "{}", no_files.result);
}
#[tokio::test]
async fn gitignored_and_hidden_entries_are_invisible() {
let f = fixture(&[
(".gitignore", "target/\nsecret.txt\n"),
("src/a.rs", "needle\n"),
("target/build.rs", "needle\n"),
("secret.txt", "needle\n"),
(".hidden/x.rs", "needle\n"),
]);
let grep = CodeTool::Grep
.invoke(&f.ctx, serde_json::json!({"pattern": "needle"}))
.await
.unwrap();
assert!(grep.result.contains("src/a.rs"), "got: {}", grep.result);
for hidden in ["target/", "secret.txt", ".hidden"] {
assert!(
!grep.result.contains(hidden),
"{hidden} must not be searched: {}",
grep.result
);
}
let list = CodeTool::List
.invoke(&f.ctx, serde_json::json!({"depth": 3}))
.await
.unwrap();
assert!(list.result.contains("src/"), "got: {}", list.result);
assert!(!list.result.contains("target/"), "got: {}", list.result);
}
#[tokio::test]
async fn list_shows_the_tree_and_marks_directories() {
let f = fixture(&[("src/a.rs", "x\n"), ("README.md", "y\n")]);
let out = CodeTool::List
.invoke(&f.ctx, serde_json::json!({}))
.await
.unwrap();
assert!(out.result.contains("src/"), "got: {}", out.result);
assert!(out.result.contains("src/a.rs"), "got: {}", out.result);
assert!(out.result.contains("README.md"), "got: {}", out.result);
}
#[tokio::test]
async fn list_respects_depth() {
let f = fixture(&[("src/deep/x.rs", "x\n")]);
let shallow = CodeTool::List
.invoke(&f.ctx, serde_json::json!({"depth": 1}))
.await
.unwrap();
assert!(!shallow.result.contains("x.rs"), "{}", shallow.result);
let deep = CodeTool::List
.invoke(&f.ctx, serde_json::json!({"depth": 3}))
.await
.unwrap();
assert!(deep.result.contains("src/deep/x.rs"), "{}", deep.result);
}
#[tokio::test]
async fn paths_outside_the_root_are_refused() {
let f = fixture(&[("a.rs", "x\n")]);
for path in ["../../secrets.txt", ".."] {
assert!(
CodeTool::Read
.invoke(&f.ctx, serde_json::json!({"path": path}))
.await
.is_err(),
"escaping the workspace must be refused: {path}"
);
}
let elsewhere = tempfile::tempdir().unwrap();
std::fs::write(elsewhere.path().join("out.txt"), "x").unwrap();
assert!(
CodeTool::Read
.invoke(
&f.ctx,
serde_json::json!({"path": elsewhere.path().join("out.txt").to_string_lossy()})
)
.await
.is_err()
);
}
#[tokio::test]
async fn without_a_workspace_every_tool_refuses() {
let f = detached();
assert!(
CodeTool::Read
.invoke(&f.ctx, serde_json::json!({"path": "Cargo.toml"}))
.await
.is_err()
);
assert!(
CodeTool::Grep
.invoke(&f.ctx, serde_json::json!({"pattern": "fn"}))
.await
.is_err()
);
assert!(
CodeTool::List
.invoke(&f.ctx, serde_json::json!({}))
.await
.is_err()
);
let err = CodeTool::List
.invoke(&f.ctx, serde_json::json!({}))
.await
.unwrap_err()
.to_string();
assert!(err.contains("/project"), "got: {err}");
let _ = f.dir;
}
#[tokio::test]
async fn a_binary_file_is_refused_rather_than_mangled() {
let f = fixture(&[]);
std::fs::write(f.dir.path().join("a.bin"), [0x00, 0x01, 0x02]).unwrap();
assert!(
CodeTool::Read
.invoke(&f.ctx, serde_json::json!({"path": "a.bin"}))
.await
.is_err()
);
}
#[test]
fn crlf_and_bom_are_normalized_for_reading_and_restored_for_writing() {
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice(b"let x = 1;\r\nlet y = 2;\r\n");
let file = TextFile::load(&bytes, false, None).unwrap();
assert_eq!(file.text, "let x = 1;\nlet y = 2;\n");
assert!(file.crlf && file.bom);
assert_eq!(file.encode(&file.text), Ok(bytes));
}
fn editable(files: &[(&str, &str)]) -> (Fixture, tempfile::TempDir) {
let mut f = fixture(files);
let journal = tempfile::tempdir().unwrap();
f.ctx.workspace_journal = Some(journal.path().join("chat"));
(f, journal)
}
#[tokio::test]
async fn edit_replaces_a_unique_fragment() {
let (f, _j) = editable(&[("a.rs", "let x = 1;\nlet y = 2;\n")]);
let out = CodeTool::Edit
.invoke(
&f.ctx,
serde_json::json!({"path": "a.rs", "old_string": "let y = 2;", "new_string": "let y = 3;"}),
)
.await
.unwrap();
assert_eq!(
std::fs::read_to_string(f.dir.path().join("a.rs")).unwrap(),
"let x = 1;\nlet y = 3;\n"
);
assert!(
out.result.contains("\u{2192}let y = 3;"),
"got: {}",
out.result
);
}
#[tokio::test]
async fn edit_refuses_a_missing_or_ambiguous_fragment_without_writing() {
let (f, _j) = editable(&[("a.rs", "dup\ndup\n")]);
let miss = CodeTool::Edit
.invoke(
&f.ctx,
serde_json::json!({"path": "a.rs", "old_string": "absent", "new_string": "x"}),
)
.await
.unwrap();
let ambiguous = CodeTool::Edit
.invoke(
&f.ctx,
serde_json::json!({"path": "a.rs", "old_string": "dup", "new_string": "x"}),
)
.await
.unwrap();
assert_ne!(miss.result, ambiguous.result, "the two must be told apart");
assert!(
ambiguous.result.contains('2'),
"the count is what makes it actionable: {}",
ambiguous.result
);
assert_eq!(
std::fs::read_to_string(f.dir.path().join("a.rs")).unwrap(),
"dup\ndup\n",
"a refused edit must not touch the file"
);
CodeTool::Edit
.invoke(
&f.ctx,
serde_json::json!({"path": "a.rs", "old_string": "dup", "new_string": "x", "replace_all": true}),
)
.await
.unwrap();
assert_eq!(
std::fs::read_to_string(f.dir.path().join("a.rs")).unwrap(),
"x\nx\n"
);
}
#[tokio::test]
async fn edit_preserves_crlf_and_bom() {
let (f, _j) = editable(&[]);
let path = f.dir.path().join("a.rs");
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice(b"let x = 1;\r\nlet y = 2;\r\n");
std::fs::write(&path, &bytes).unwrap();
CodeTool::Edit
.invoke(
&f.ctx,
serde_json::json!({"path": "a.rs", "old_string": "let y = 2;", "new_string": "let y = 3;"}),
)
.await
.unwrap();
let mut want = vec![0xEF, 0xBB, 0xBF];
want.extend_from_slice(b"let x = 1;\r\nlet y = 3;\r\n");
assert_eq!(std::fs::read(&path).unwrap(), want);
}
const LEGACY_SOURCE: &str = "// Расчёт скидки для постоянного покупателя.\r\n\
fn discount(orders: u32) -> u32 {\r\n // Скидка растёт с каждым десятым заказом.\r\n\
\x20 orders / 10\r\n}\r\n";
fn cp1251(text: &str) -> Vec<u8> {
encoding_rs::WINDOWS_1251.encode(text).0.into_owned()
}
fn legacy_project(bytes: &[u8]) -> (Fixture, tempfile::TempDir, std::path::PathBuf) {
let (mut f, journal) = editable(&[]);
f.ctx.file_hint = Some("ru");
let path = f.dir.path().join("discount.rs");
std::fs::write(&path, bytes).unwrap();
(f, journal, path)
}
async fn edit_discount(f: &Fixture, new: &str) -> String {
CodeTool::Edit
.invoke(
&f.ctx,
serde_json::json!({"path": "discount.rs", "old_string": "orders / 10", "new_string": new}),
)
.await
.unwrap()
.result
}
#[tokio::test]
async fn a_legacy_file_is_read_searched_and_edited_in_its_own_encoding() {
let (f, _j, path) = legacy_project(&cp1251(LEGACY_SOURCE));
let read = CodeTool::Read
.invoke(&f.ctx, serde_json::json!({"path": "discount.rs"}))
.await
.unwrap()
.result;
assert!(
read.contains("windows-1251") && read.contains("Скидка растёт"),
"{read}"
);
let grep = CodeTool::Grep
.invoke(&f.ctx, serde_json::json!({"pattern": "Скидка"}))
.await
.unwrap()
.result;
assert!(grep.contains("discount.rs:3:"), "{grep}");
edit_discount(&f, "orders / 5").await;
let want = cp1251(&LEGACY_SOURCE.replace("orders / 10", "orders / 5"));
assert_eq!(std::fs::read(&path).unwrap(), want);
}
#[tokio::test]
async fn an_edit_the_file_s_encoding_cannot_carry_writes_nothing() {
let legacy = cp1251(LEGACY_SOURCE);
let (f, journal, path) = legacy_project(&legacy);
let out = edit_discount(&f, "orders / 10 // 🙂").await;
assert!(out.contains('🙂') && out.contains("windows-1251"), "{out}");
assert_eq!(std::fs::read(&path).unwrap(), legacy);
let lossy = [LEGACY_SOURCE.as_bytes(), &[0xFF]].concat();
std::fs::write(&path, &lossy).unwrap();
let out = edit_discount(&f, "orders / 5").await;
assert!(out.contains("UTF-8"), "{out}");
assert_eq!(std::fs::read(&path).unwrap(), lossy);
assert!(
!journal.path().join("chat").exists(),
"a refused edit must not be journaled"
);
}
#[tokio::test]
async fn a_utf16_file_is_text_and_an_edit_comes_back_in_utf16() {
let utf16 = |text: &str| -> Vec<u8> {
[0xFF, 0xFE]
.into_iter()
.chain(text.encode_utf16().flat_map(u16::to_le_bytes))
.collect()
};
let (f, _j, path) = legacy_project(&utf16(LEGACY_SOURCE));
edit_discount(&f, "orders / 5").await;
let want = utf16(&LEGACY_SOURCE.replace("orders / 10", "orders / 5"));
assert_eq!(std::fs::read(&path).unwrap(), want);
}
#[tokio::test]
async fn write_keeps_an_existing_file_s_encoding() {
let (f, _j, path) = legacy_project(&cp1251(LEGACY_SOURCE));
CodeTool::Write
.invoke(
&f.ctx,
serde_json::json!({"path": "discount.rs", "content": "// Скидки больше нет.\nfn discount() -> u32 { 0 }\n"}),
)
.await
.unwrap();
let want = cp1251("// Скидки больше нет.\r\nfn discount() -> u32 { 0 }\r\n");
assert_eq!(std::fs::read(&path).unwrap(), want);
}
#[tokio::test]
async fn an_edit_journals_the_original_once() {
let (f, journal) = editable(&[("a.rs", "one\n")]);
let j = crate::features::workspace_journal::Journal::new(journal.path().join("chat"));
for (old, new) in [("one", "two"), ("two", "three")] {
CodeTool::Edit
.invoke(
&f.ctx,
serde_json::json!({"path": "a.rs", "old_string": old, "new_string": new}),
)
.await
.unwrap();
}
assert_eq!(
std::fs::read_to_string(f.dir.path().join("a.rs")).unwrap(),
"three\n"
);
assert_eq!(
j.baseline_of("a.rs").as_deref(),
Some(&b"one\n"[..]),
"the baseline must be the file before the *first* edit"
);
assert_eq!(j.entries().len(), 1);
}
#[tokio::test]
async fn an_edit_that_cannot_be_journaled_does_not_happen() {
let (mut f, journal) = editable(&[("a.rs", "one\n")]);
let blocked = journal.path().join("blocked");
std::fs::write(&blocked, "not a directory").unwrap();
f.ctx.workspace_journal = Some(blocked);
let err = CodeTool::Edit
.invoke(
&f.ctx,
serde_json::json!({"path": "a.rs", "old_string": "one", "new_string": "two"}),
)
.await
.unwrap_err();
assert_eq!(
std::fs::read_to_string(f.dir.path().join("a.rs")).unwrap(),
"one\n",
"the file must be untouched: {err}"
);
}
#[tokio::test]
async fn without_a_journal_editing_refuses() {
let f = fixture(&[("a.rs", "one\n")]);
assert!(f.ctx.workspace_journal.is_none());
assert!(
CodeTool::Edit
.invoke(
&f.ctx,
serde_json::json!({"path": "a.rs", "old_string": "one", "new_string": "two"}),
)
.await
.is_err()
);
}
#[tokio::test]
async fn write_creates_a_file_with_its_parents_and_journals_it_as_new() {
let (f, journal) = editable(&[]);
let out = CodeTool::Write
.invoke(
&f.ctx,
serde_json::json!({"path": "src/deep/new.rs", "content": "fn main() {}\n"}),
)
.await
.unwrap();
assert_eq!(
std::fs::read_to_string(f.dir.path().join("src/deep/new.rs")).unwrap(),
"fn main() {}\n"
);
assert!(
out.result.contains("src/deep/new.rs"),
"got: {}",
out.result
);
let j = crate::features::workspace_journal::Journal::new(journal.path().join("chat"));
let entries = j.entries();
assert_eq!(entries.len(), 1);
assert!(
!entries[0].existed,
"reverting a created file deletes it, so the entry must say it was new"
);
}
#[tokio::test]
async fn write_keeps_an_existing_file_s_line_endings() {
let (f, _j) = editable(&[]);
let path = f.dir.path().join("a.rs");
std::fs::write(&path, b"old\r\n").unwrap();
CodeTool::Write
.invoke(
&f.ctx,
serde_json::json!({"path": "a.rs", "content": "new\nlines\n"}),
)
.await
.unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new\r\nlines\r\n");
}
#[tokio::test]
async fn a_missing_chain_cannot_be_used_to_escape() {
let (f, _j) = editable(&[]);
for path in ["new_dir/../../escaped.rs", "a/b/c/../../../../escaped.rs"] {
assert!(
CodeTool::Write
.invoke(&f.ctx, serde_json::json!({"path": path, "content": "x"}))
.await
.is_err(),
"must be refused: {path}"
);
}
assert!(
CodeTool::Write
.invoke(
&f.ctx,
serde_json::json!({"path": "deep/nested/ok.rs", "content": "x"})
)
.await
.is_ok()
);
}
#[tokio::test]
async fn writing_outside_the_root_is_refused() {
let (f, _j) = editable(&[]);
assert!(
CodeTool::Write
.invoke(
&f.ctx,
serde_json::json!({"path": "../escaped.rs", "content": "x"}),
)
.await
.is_err()
);
}
#[test]
fn only_the_writing_tools_are_dangerous_and_none_spend_a_round() {
assert!(CodeTool::Edit.danger() && CodeTool::Write.danger());
assert!(!CodeTool::Read.danger() && !CodeTool::Grep.danger() && !CodeTool::List.danger());
for exempt in [
CodeTool::Edit.counts_toward_round_limit(),
CodeTool::Write.counts_toward_round_limit(),
CodeTool::Read.counts_toward_round_limit(),
CodeTool::Grep.counts_toward_round_limit(),
CodeTool::List.counts_toward_round_limit(),
] {
assert!(!exempt, "the workspace family does not spend the budget");
}
}
#[test]
fn the_family_list_matches_the_predicate() {
for id in WORKSPACE_TOOL_IDS {
assert!(is_workspace_tool(id), "{id}");
}
assert!(!is_workspace_tool("fs_read"));
}
static SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
fn with_command(slot: CommandSlot, line: &str) -> Fixture {
let mut f = fixture(&[("a.rs", "fn main() {}\n")]);
let ws = f.ctx.workspace.as_mut().expect("the fixture attaches one");
ws.set_command(slot, Some(line.to_string()));
f
}
#[tokio::test]
async fn a_slot_with_no_line_names_the_command_that_fills_it() {
let f = fixture(&[]);
for slot in CommandSlot::ALL {
let err = CodeTool::Command(slot)
.invoke(&f.ctx, serde_json::json!({}))
.await
.expect_err("no line means no run");
let msg = err.to_string();
assert!(
msg.contains(&format!("/project {}-cmd", slot.key())),
"{slot:?}: {msg}"
);
}
}
#[tokio::test]
async fn a_pipeline_that_reached_the_tool_is_refused_by_name() {
let f = with_command(CommandSlot::Build, "cargo build 2>&1 | tee log.txt");
let err = CodeTool::Command(CommandSlot::Build)
.invoke(&f.ctx, serde_json::json!({}))
.await
.expect_err("a pipeline cannot run without a shell");
let msg = err.to_string();
assert!(msg.contains('|') || msg.contains('>'), "{msg}");
assert!(
msg.contains("cargo build 2>&1 | tee log.txt"),
"the line the user typed must be quoted back: {msg}"
);
assert!(!f.dir.path().join("log.txt").exists());
}
#[tokio::test]
async fn a_command_reports_its_output_and_its_exit_code() {
let _serial = SERIAL.lock().await;
let Some(py) = crate::shared::proc::test_python() else {
println!("SKIP: no python interpreter for the command fixture");
return;
};
let f = with_command(
CommandSlot::Test,
&format!("{py} -c \"import sys; print('out'); sys.stderr.write('err'); sys.exit(3)\""),
);
let out = CodeTool::Command(CommandSlot::Test)
.invoke(&f.ctx, serde_json::json!({}))
.await
.unwrap()
.result;
assert!(out.contains("command (2 lines):\n"), "{out}");
assert!(out.contains("out"), "stdout is missing: {out}");
assert!(out.contains("err"), "stderr is missing: {out}");
assert!(out.contains('3'), "the exit code is missing: {out}");
let console = super::super::present::present(
CODE_TEST_ID,
"{}",
&out,
super::super::present::ArgDetail::Compact,
);
assert!(
console
.result
.iter()
.any(|b| matches!(b, super::super::present::ToolBlock::Console(_))),
"the result must render as a console: {console:?}"
);
}
#[tokio::test]
async fn a_command_runs_in_the_project_root() {
let _serial = SERIAL.lock().await;
let Some(py) = crate::shared::proc::test_python() else {
println!("SKIP: no python interpreter for the command fixture");
return;
};
let f = with_command(
CommandSlot::Run,
&format!("{py} -c \"import pathlib; pathlib.Path('here.txt').write_text('x')\""),
);
CodeTool::Command(CommandSlot::Run)
.invoke(&f.ctx, serde_json::json!({}))
.await
.unwrap();
assert!(
f.dir.path().join("here.txt").is_file(),
"the command's working directory was not the project root"
);
}
#[tokio::test]
async fn a_timed_out_command_keeps_what_it_printed() {
let _serial = SERIAL.lock().await;
let Some(py) = crate::shared::proc::test_python() else {
println!("SKIP: no python interpreter for the command fixture");
return;
};
let mut f = with_command(
CommandSlot::Build,
&format!(
"{py} -c \"import sys,time; print('early'); sys.stdout.flush(); time.sleep(60)\""
),
);
f.ctx.workspace_cfg.command_timeout_secs = 1;
let out = CodeTool::Command(CommandSlot::Build)
.invoke(&f.ctx, serde_json::json!({}))
.await
.unwrap()
.result;
assert!(out.contains("early"), "partial output was discarded: {out}");
let timed_out = f.ctx.loc.tf("tool.code.cmd.timed_out", &[("secs", "1")]);
assert!(out.contains(&timed_out), "{out}");
assert!(
!out.contains("-1"),
"a fabricated exit code for a killed command: {out}"
);
}
#[tokio::test]
async fn a_second_command_is_refused_while_one_runs() {
let Some(py) = crate::shared::proc::test_python() else {
println!("SKIP: no python interpreter for the command fixture");
return;
};
let _serial = SERIAL.lock().await;
let slow = with_command(
CommandSlot::Run,
&format!("{py} -c \"import time; time.sleep(30)\""),
);
let mut first = slow;
first.ctx.workspace_cfg.command_timeout_secs = 1;
let ctx = first.ctx.clone();
let running = tokio::spawn(async move {
CodeTool::Command(CommandSlot::Run)
.invoke(&ctx, serde_json::json!({}))
.await
});
for _ in 0..100 {
if COMMAND_GATE.available_permits() == 0 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
let second = with_command(CommandSlot::Build, &format!("{py} -c \"print(1)\""));
let err = CodeTool::Command(CommandSlot::Build)
.invoke(&second.ctx, serde_json::json!({}))
.await
.expect_err("the second command must be refused, not queued");
assert!(
err.to_string() == second.ctx.loc.t("tool.code.cmd.busy"),
"the refusal must name the reason: {err}"
);
let _ = running.await;
}
#[test]
fn clipping_keeps_both_ends() {
let loc = crate::shared::i18n::locale(crate::shared::i18n::Lang::En);
let body: String = (1..=200).map(|i| format!("line {i}\n")).collect();
let clipped = clip(&body, 300, loc);
assert!(clipped.contains("line 1\n"), "the head is gone: {clipped}");
assert!(clipped.contains("line 200"), "the tail is gone: {clipped}");
assert!(
!clipped.contains("line 100\n"),
"the middle should have gone instead: {clipped}"
);
assert_eq!(clip("short", 300, loc), "short");
}
#[test]
fn ansi_escapes_are_stripped() {
assert_eq!(strip_ansi("\u{1b}[31merror\u{1b}[0m: x"), "error: x");
assert_eq!(strip_ansi("\u{1b}]0;title\u{7}ok"), "ok");
assert_eq!(strip_ansi("plain"), "plain");
}
#[test]
fn ansi_stripping_covers_the_less_common_terminators() {
assert_eq!(strip_ansi("\u{1b}]0;title\u{1b}\\ok"), "ok");
assert_eq!(strip_ansi("a\u{1b}cb"), "ab");
assert_eq!(strip_ansi("a\u{1b}[31"), "a");
assert_eq!(strip_ansi("a\u{1b}]0;title"), "a");
assert_eq!(strip_ansi("a\u{1b}"), "a");
}
#[test]
fn a_grep_hit_is_clipped_by_characters() {
assert_eq!(clip_hit("short"), "short");
let long = "п".repeat(MAX_GREP_LINE + 50);
let clipped = clip_hit(&long);
assert_eq!(clipped.chars().count(), MAX_GREP_LINE + 1, "the … is extra");
assert!(clipped.ends_with('…'));
}
#[tokio::test]
async fn a_project_containing_the_data_root_does_not_reach_it() {
let mut f = fixture(&[
("src/main.rs", "fn main() {} // marker-xyz\n"),
("appdata/settings.json", "{\"marker-xyz\": true}\n"),
]);
let data_root = f.dir.path().join("appdata");
f.ctx.storage = std::sync::Arc::new(
crate::shared::storage::Storage::open_in_memory(
crate::shared::paths::Paths::with_root(&data_root),
)
.unwrap(),
);
let refusal = f.ctx.loc.t("tool.fs.err.app_dir");
let read = CodeTool::Read
.invoke(&f.ctx, serde_json::json!({"path": "appdata/settings.json"}))
.await;
assert_eq!(read.unwrap_err().to_string(), refusal);
let write = CodeTool::Write
.invoke(
&f.ctx,
serde_json::json!({"path": "appdata/settings.json", "content": "{}"}),
)
.await;
assert_eq!(write.unwrap_err().to_string(), refusal);
assert!(
std::fs::read_to_string(data_root.join("settings.json"))
.unwrap()
.contains("marker-xyz")
);
let list = CodeTool::List
.invoke(&f.ctx, serde_json::json!({}))
.await
.unwrap()
.result;
assert!(
list.contains("src/main.rs") && !list.contains("appdata"),
"{list}"
);
let grep = CodeTool::Grep
.invoke(&f.ctx, serde_json::json!({"pattern": "marker-xyz"}))
.await
.unwrap()
.result;
assert!(
grep.contains("main.rs") && !grep.contains("settings.json"),
"{grep}"
);
f.ctx.workspace = Some(Workspace::new(data_root.to_string_lossy().into_owned()));
let inside = CodeTool::List.invoke(&f.ctx, serde_json::json!({})).await;
assert_eq!(inside.unwrap_err().to_string(), refusal);
}
#[tokio::test]
async fn git_internals_are_readable_but_not_written() {
let f = fixture(&[
(".git/config", "[core]\n"),
(".git/hooks/pre-commit.sample", "#!/bin/sh\n"),
]);
let refusal = f.ctx.loc.t("tool.code.err.git_dir");
let hook = CodeTool::Write
.invoke(
&f.ctx,
serde_json::json!({"path": ".git/hooks/pre-commit", "content": "#!/bin/sh\ncurl x\n"}),
)
.await;
assert_eq!(hook.unwrap_err().to_string(), refusal);
assert!(!f.dir.path().join(".git/hooks/pre-commit").exists());
let edit = CodeTool::Edit
.invoke(
&f.ctx,
serde_json::json!({"path": ".GIT/config", "old_string": "[core]", "new_string": "[alias]"}),
)
.await;
assert_eq!(edit.unwrap_err().to_string(), refusal);
let read = CodeTool::Read
.invoke(
&f.ctx,
serde_json::json!({"path": ".git/hooks/pre-commit.sample"}),
)
.await
.unwrap();
assert!(read.result.contains("#!/bin/sh"), "{}", read.result);
}
#[cfg(unix)]
#[tokio::test]
async fn a_dangling_link_in_the_project_is_not_written_through() {
let f = fixture(&[("src/lib.rs", "\n")]);
let outside = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(
outside.path().join("escaped.txt"),
f.dir.path().join("notes.md"),
)
.unwrap();
std::os::unix::fs::symlink(
outside.path().join("missing-dir"),
f.dir.path().join("linked"),
)
.unwrap();
let refusal = f.ctx.loc.t("tool.fs.err.dangling_link");
for path in ["notes.md", "linked/new.rs"] {
let write = CodeTool::Write
.invoke(&f.ctx, serde_json::json!({"path": path, "content": "x"}))
.await;
assert_eq!(write.unwrap_err().to_string(), refusal, "{path}");
}
assert!(!outside.path().join("escaped.txt").exists());
assert!(!outside.path().join("missing-dir").exists());
}
}