feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
[package]
|
||||
name = "buzz-dev-mcp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "buzz_dev_mcp"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "buzz-dev-mcp"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
buzz-cli = { path = "../buzz-cli" }
|
||||
git-credential-nostr = { path = "../git-credential-nostr" }
|
||||
git-sign-nostr = { path = "../git-sign-nostr" }
|
||||
nostr = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
rmcp = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
similar = "3"
|
||||
tempfile = "3"
|
||||
ignore = "0.4.25"
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
# wss:// tool calls use rustls via tokio-tungstenite/reqwest. The workspace
|
||||
# pulls both ring and aws-lc-rs transitively, so install ring explicitly.
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
# view_image tool: HTTP fetch (workspace reqwest is already used by buzz-cli;
|
||||
# adding it here is essentially free), base64 encoding, and decode/resize.
|
||||
reqwest = { workspace = true }
|
||||
base64 = "0.22"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
|
||||
buzz-core = { workspace = true }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
nix = { version = "0.31", default-features = false, features = ["signal", "process"] }
|
||||
|
||||
# Windows Job Object APIs for the shell tool's timeout kill path: terminating a
|
||||
# job kills the bash child AND every MSYS grandchild it forked, the Windows
|
||||
# analogue of the Unix killpg above. windows-sys 0.61 is already workspace-
|
||||
# resident (pulled transitively), so this adds no new crate. Win32_Security is
|
||||
# required because CreateJobObjectW takes a SECURITY_ATTRIBUTES parameter;
|
||||
# Win32_System_Threading supplies IO_COUNTERS inside the extended-limit struct.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading"] }
|
||||
@@ -0,0 +1,213 @@
|
||||
#![cfg_attr(not(windows), forbid(unsafe_code))]
|
||||
#![cfg_attr(windows, deny(unsafe_code))]
|
||||
use rmcp::{
|
||||
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
||||
model::{CallToolResult, ServerCapabilities, ServerInfo},
|
||||
tool, tool_handler, tool_router,
|
||||
transport::stdio,
|
||||
ErrorData, ServerHandler, ServiceExt,
|
||||
};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod paths;
|
||||
mod read_file;
|
||||
mod rg;
|
||||
mod shell;
|
||||
mod shim;
|
||||
mod str_replace;
|
||||
mod todo;
|
||||
mod tree;
|
||||
mod view_image;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DevMcp {
|
||||
state: Arc<shell::SharedState>,
|
||||
todos: Arc<todo::TodoState>,
|
||||
tool_router: ToolRouter<DevMcp>,
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl DevMcp {
|
||||
fn new(state: Arc<shell::SharedState>) -> Self {
|
||||
Self {
|
||||
state,
|
||||
todos: Arc::new(todo::TodoState::new()),
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "shell",
|
||||
description = "Run a shell command (bash by default; set `BUZZ_SHELL` to use cmd, PowerShell, or another shell). Ephemeral process per call. Output tail-truncated to ~8KB for the LLM; full output (first 10MB) saved to artifact file. timeout_ms defaults to 120000 (2 min) if omitted; capped at 600000 (10 min). For long-running commands (git push with hooks, cargo build, test suites), use 300000+. On PATH: rg (prefer over grep; flags: -n -i -l -g <glob> -C <n> --files), tree (flags: -d <depth>; shows line counts), and buzz (Buzz relay CLI — run buzz --help for commands)."
|
||||
)]
|
||||
async fn shell(
|
||||
&self,
|
||||
Parameters(p): Parameters<shell::ShellParams>,
|
||||
context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
shell::run(&self.state, p, context.ct).await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "read_file",
|
||||
description = "Read a text file and return its contents with line numbers. Returns lines in `{number}:{content}` format. Use `offset` (0-based) and `limit` (default 2000) to window into large files. Path resolved relative to workdir (defaults to server cwd). Prefer over cat/head/tail."
|
||||
)]
|
||||
async fn read_file(
|
||||
&self,
|
||||
Parameters(p): Parameters<read_file::ReadFileParams>,
|
||||
) -> Result<String, ErrorData> {
|
||||
read_file::run(&self.state, p)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "view_image",
|
||||
description = "Load an image from a file path, http(s) URL, or data: URL and return it as an MCP image content block that multimodal LLMs (Anthropic, OpenAI-compatible, etc.) can see. Resizes to a longest-edge of 1568px by default (override with `max_dim`, range 64..=2048). Pass-through for already-small PNG/JPEG; transcodes oversize input to PNG (if alpha) or JPEG q85. Animated GIF/WebP rejected — provide a still frame. Hard cap 20 MiB source, ~4 MiB on the wire. Relative paths resolve under `workdir` (defaults to server cwd) and may not escape it."
|
||||
)]
|
||||
async fn view_image(
|
||||
&self,
|
||||
Parameters(p): Parameters<view_image::ViewImageParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
view_image::run(&self.state, p).await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "str_replace",
|
||||
description = "Atomic find-and-replace in a file. old_str must occur exactly once unless replace_all is true, in which case all occurrences are replaced. Returns a unified diff. Path resolved relative to workdir (defaults to server cwd). Prefer over sed/awk."
|
||||
)]
|
||||
async fn str_replace(
|
||||
&self,
|
||||
Parameters(p): Parameters<str_replace::StrReplaceParams>,
|
||||
) -> Result<String, ErrorData> {
|
||||
str_replace::run(&self.state, p)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "todo",
|
||||
description = "Session task list. Omit `todos` to read current state. Provide a full replacement array to update. Items are {text, done}. Open items removed without being marked done will trigger a warning. If the operator enables hooks for this server, the agent's _Stop hook will advise against ending the turn while items are open."
|
||||
)]
|
||||
async fn todo(
|
||||
&self,
|
||||
Parameters(p): Parameters<todo::TodoParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
match self.todos.handle_todo(p) {
|
||||
Ok(text) => todo::text_result(text),
|
||||
Err(e) => todo::error_result(format!("Error: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hook: called by the agent before honoring end_turn. Returns
|
||||
/// non-empty objection text iff items remain open.
|
||||
#[tool(
|
||||
name = "_Stop",
|
||||
description = "Returns open todo items if any exist. Used by the agent's _Stop lifecycle hook to advise against ending with incomplete work."
|
||||
)]
|
||||
async fn stop_hook(
|
||||
&self,
|
||||
Parameters(_): Parameters<todo::HookParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
todo::text_result(self.todos.stop_objection())
|
||||
}
|
||||
|
||||
/// Hook: called by the agent after context compaction/handoff so the
|
||||
/// todo list survives history truncation.
|
||||
#[tool(
|
||||
name = "_PostCompact",
|
||||
description = "Internal hook. Agent invokes after handoff; returns todo state for re-injection."
|
||||
)]
|
||||
async fn post_compact_hook(
|
||||
&self,
|
||||
Parameters(_): Parameters<todo::HookParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
todo::text_result(self.todos.post_compact())
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler(router = self.tool_router)]
|
||||
impl ServerHandler for DevMcp {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
||||
.with_server_info(rmcp::model::Implementation::new(
|
||||
"buzz-dev-mcp",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
))
|
||||
.with_instructions(self.state.bootstrap_instructions.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let argv0 = std::env::args().next().unwrap_or_default();
|
||||
let cmd = Path::new(&argv0)
|
||||
.file_stem()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
|
||||
// Multicall dispatch — sync personalities exit before any runtime is built.
|
||||
// No tracing, no tokio, no allocations beyond argv parsing.
|
||||
match cmd.as_str() {
|
||||
"rg" => std::process::exit(rg::run(std::env::args().skip(1).collect())),
|
||||
"tree" => std::process::exit(tree::run(std::env::args().skip(1).collect())),
|
||||
"git-credential-nostr" => std::process::exit(git_credential_nostr::run()),
|
||||
"git-sign-nostr" => std::process::exit(git_sign_nostr::run()),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Async personalities and MCP server mode — build the runtime.
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(async_main(cmd))
|
||||
}
|
||||
|
||||
async fn async_main(cmd: String) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// HTTPS clients invoked through this MCP process need a Rustls provider;
|
||||
// repeated installation is harmless.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
// buzz CLI needs tokio (async HTTP client).
|
||||
if cmd == "buzz" {
|
||||
std::process::exit(buzz_cli::run_from_args(std::env::args()).await);
|
||||
}
|
||||
|
||||
// MCP server mode — safe to init tracing now.
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_ansi(false)
|
||||
.init();
|
||||
|
||||
let cwd = std::env::current_dir()?;
|
||||
let shim = shim::Shim::install()?;
|
||||
let state = Arc::new(shell::SharedState::new(cwd, shim)?);
|
||||
|
||||
let service = DevMcp::new(state).serve(stdio()).await?;
|
||||
service.waiting().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Suppress the console window that Windows otherwise allocates for every
|
||||
/// console-subsystem child process spawned from a non-console parent.
|
||||
/// No-op on non-Windows platforms.
|
||||
pub(crate) fn configure_no_window(cmd: &mut std::process::Command) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
let _ = cmd;
|
||||
}
|
||||
|
||||
/// Suppress the console window for async (`tokio::process::Command`) spawns.
|
||||
/// Equivalent to `configure_no_window` but accepts a tokio command.
|
||||
/// No-op on non-Windows platforms.
|
||||
pub(crate) fn configure_no_window_async(cmd: &mut tokio::process::Command) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
let _ = cmd;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
buzz_dev_mcp::run()
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Path resolution and file I/O shared across dev-mcp tools.
|
||||
//!
|
||||
//! `resolve_path` resolves and canonicalizes a user-supplied path against a
|
||||
//! workspace root. No containment enforcement — the resolved path may land
|
||||
//! anywhere on the filesystem (consistent with the `shell` tool's posture).
|
||||
//!
|
||||
//! `read_text_file` builds on `resolve_path` to provide the full
|
||||
//! resolve → stat → size-check → read → UTF-8 decode pipeline shared by
|
||||
//! `read_file` and `str_replace`.
|
||||
|
||||
use crate::shell::SharedState;
|
||||
use rmcp::ErrorData;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024;
|
||||
|
||||
/// Resolve `path` (absolute or relative) against `root` and canonicalize
|
||||
/// the result. Returns an error string suitable for `ErrorData::invalid_params`
|
||||
/// if the path cannot be resolved.
|
||||
pub(crate) fn resolve_path(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
// The agent runs inside MSYS bash and naturally hands us MSYS-form absolute
|
||||
// paths (`/c/Users/...`). On Windows those are NOT `is_absolute()` (a leading
|
||||
// `/` has no drive `Prefix`), so without translation they'd take the relative
|
||||
// branch and `root.join` would double the drive (`C:/c/Users/...`) and fail.
|
||||
// The `shell` tool avoids this because bash translates internally; the MCP
|
||||
// file tools call `canonicalize` directly, so we mirror that translation here
|
||||
// to keep the same posture. No-op on the already-resolved path on Unix.
|
||||
#[cfg(windows)]
|
||||
let path = &msys_to_windows(path);
|
||||
|
||||
let raw = Path::new(path);
|
||||
let candidate: PathBuf = if raw.is_absolute() {
|
||||
raw.to_path_buf()
|
||||
} else {
|
||||
root.join(raw)
|
||||
};
|
||||
|
||||
let resolved = std::fs::canonicalize(&candidate)
|
||||
.map_err(|e| format!("path not accessible: {} ({e})", candidate.display()))?;
|
||||
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
/// Translate the MSYS/Cygwin absolute path forms bash would accept into a
|
||||
/// native Windows path, matching `cygpath -w` semantics so the file tools
|
||||
/// resolve the same inputs the `shell` tool does. Anything that is not a
|
||||
/// recognized MSYS-absolute form is returned unchanged.
|
||||
///
|
||||
/// Two forms are translated natively because they are deterministic with no
|
||||
/// external state:
|
||||
/// - cygdrive: `/c/Users/x` -> `C:\Users\x` (the form that bit the agent).
|
||||
/// - UNC: `//server/share/x` -> `\\server\share\x`.
|
||||
///
|
||||
/// A third form — root-anchored `/tmp`, `/usr/...`, `/bin` — maps under the
|
||||
/// MSYS install root (from the host's Git for Windows install), which this
|
||||
/// process does not reliably know. We deliberately do NOT guess it: such a path
|
||||
/// falls through untranslated and fails with the clear `path not accessible`
|
||||
/// error rather than being silently mis-mapped to the wrong location. Resolving
|
||||
/// it correctly would require shelling out to `cygpath`; that is out of scope
|
||||
/// here and these paths are not a normal target for agent file I/O.
|
||||
#[cfg(windows)]
|
||||
fn msys_to_windows(path: &str) -> String {
|
||||
// UNC: exactly two leading slashes then a non-empty host segment.
|
||||
if let Some(rest) = path.strip_prefix("//") {
|
||||
if !rest.is_empty() && !rest.starts_with('/') {
|
||||
return format!(r"\\{}", rest.replace('/', r"\"));
|
||||
}
|
||||
return path.to_string();
|
||||
}
|
||||
|
||||
// cygdrive: `/<letter>` optionally followed by `/...`. The drive segment is
|
||||
// a single ASCII letter; `/cc/...` (two letters) is a root-anchored path,
|
||||
// not a drive, and must NOT match.
|
||||
if let Some(rest) = path.strip_prefix('/') {
|
||||
let mut chars = rest.chars();
|
||||
if let Some(drive) = chars.next() {
|
||||
if drive.is_ascii_alphabetic() {
|
||||
let after = chars.as_str();
|
||||
if after.is_empty() {
|
||||
// `/c` -> `C:\`
|
||||
return format!(r"{}:\", drive.to_ascii_uppercase());
|
||||
}
|
||||
if let Some(tail) = after.strip_prefix('/') {
|
||||
// `/c/Users/x` -> `C:\Users\x`
|
||||
return format!(
|
||||
r"{}:\{}",
|
||||
drive.to_ascii_uppercase(),
|
||||
tail.replace('/', r"\")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Root-anchored (`/tmp`, `/usr/...`) or anything else: leave untouched.
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
/// Resolve a user-supplied path within the workspace, read the file, and
|
||||
/// return `(resolved_path, utf8_content)`. Rejects files that are not
|
||||
/// regular files, exceed `MAX_FILE_BYTES`, or are not valid UTF-8.
|
||||
pub(crate) fn read_text_file(
|
||||
state: &SharedState,
|
||||
path: &str,
|
||||
workdir: Option<&str>,
|
||||
) -> Result<(PathBuf, String), ErrorData> {
|
||||
let workspace_root: PathBuf = match workdir {
|
||||
Some(w) => PathBuf::from(w),
|
||||
None => state.cwd.clone(),
|
||||
};
|
||||
let target = match resolve_path(&workspace_root, path) {
|
||||
Ok(t) => t,
|
||||
Err(e) => return Err(ErrorData::invalid_params(e, None)),
|
||||
};
|
||||
|
||||
let meta = match std::fs::metadata(&target) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
return Err(ErrorData::internal_error(
|
||||
format!("cannot stat {}: {e}", target.display()),
|
||||
None,
|
||||
));
|
||||
}
|
||||
};
|
||||
if !meta.is_file() {
|
||||
return Err(ErrorData::invalid_params(
|
||||
format!("not a regular file: {}", target.display()),
|
||||
None,
|
||||
));
|
||||
}
|
||||
if meta.len() > MAX_FILE_BYTES {
|
||||
return Err(ErrorData::invalid_params(
|
||||
format!(
|
||||
"file too large: {} is {} bytes (limit {} bytes)",
|
||||
target.display(),
|
||||
meta.len(),
|
||||
MAX_FILE_BYTES
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
let file = match std::fs::File::open(&target) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
return Err(ErrorData::internal_error(
|
||||
format!("cannot open {}: {e}", target.display()),
|
||||
None,
|
||||
));
|
||||
}
|
||||
};
|
||||
let mut buf = Vec::with_capacity(meta.len() as usize);
|
||||
use std::io::Read;
|
||||
match file.take(MAX_FILE_BYTES + 1).read_to_end(&mut buf) {
|
||||
Ok(n) if n as u64 > MAX_FILE_BYTES => {
|
||||
return Err(ErrorData::invalid_params(
|
||||
format!("file grew past {} bytes during read", MAX_FILE_BYTES),
|
||||
None,
|
||||
));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
return Err(ErrorData::internal_error(
|
||||
format!("cannot read {}: {e}", target.display()),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
let content = match String::from_utf8(buf) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return Err(ErrorData::internal_error(
|
||||
format!("not valid UTF-8: {}: {e}", target.display()),
|
||||
None,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
Ok((target, content))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn resolve_path_allows_outside_workspace() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let inside = dir.path().join("file.txt");
|
||||
fs::write(&inside, b"x").expect("write");
|
||||
// Symlink targeting outside the dir should now resolve successfully.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let outside = std::env::temp_dir().join("dev-mcp-paths-escape-target");
|
||||
let _ = fs::remove_file(&outside);
|
||||
fs::write(&outside, b"y").expect("write outside");
|
||||
let link = dir.path().join("link.txt");
|
||||
std::os::unix::fs::symlink(&outside, &link).expect("symlink");
|
||||
let resolved = resolve_path(dir.path(), "link.txt").expect("resolve");
|
||||
let outside_canon = std::fs::canonicalize(&outside).expect("canonicalize");
|
||||
assert_eq!(resolved, outside_canon);
|
||||
let _ = fs::remove_file(&outside);
|
||||
}
|
||||
// Resolves a normal path inside.
|
||||
let p = resolve_path(dir.path(), "file.txt").expect("resolve");
|
||||
assert!(p.ends_with("file.txt"));
|
||||
}
|
||||
|
||||
// Windows MSYS-absolute path translation. These test `msys_to_windows`
|
||||
// directly (the pure rewrite) rather than `resolve_path`, because the latter
|
||||
// canonicalizes against the real filesystem and we want deterministic
|
||||
// assertions that don't depend on `C:\Users\x` existing on the runner.
|
||||
#[cfg(windows)]
|
||||
mod windows_msys {
|
||||
use super::super::*;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn cygdrive_path_becomes_drive_letter() {
|
||||
assert_eq!(msys_to_windows("/c/Users/x"), r"C:\Users\x");
|
||||
assert_eq!(msys_to_windows("/d/a/_temp/repo"), r"D:\a\_temp\repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cygdrive_root_becomes_drive_root() {
|
||||
assert_eq!(msys_to_windows("/c"), r"C:\");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unc_path_becomes_backslash_unc() {
|
||||
assert_eq!(msys_to_windows("//server/share/x"), r"\\server\share\x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_absolute_passes_through_unchanged() {
|
||||
// Already a native Windows path — must not be mangled.
|
||||
assert_eq!(msys_to_windows(r"C:\Users\x"), r"C:\Users\x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_path_passes_through_unchanged() {
|
||||
// No leading slash — left for the caller's `root.join`.
|
||||
assert_eq!(msys_to_windows("file.txt"), "file.txt");
|
||||
assert_eq!(msys_to_windows("sub/file.txt"), "sub/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_anchored_msys_path_is_left_untranslated() {
|
||||
// Form 3: maps under the MSYS install root we don't know — must NOT
|
||||
// be guessed. Returned unchanged so it fails cleanly downstream
|
||||
// rather than being silently mis-mapped.
|
||||
assert_eq!(msys_to_windows("/tmp/scratch"), "/tmp/scratch");
|
||||
assert_eq!(msys_to_windows("/usr/bin/git"), "/usr/bin/git");
|
||||
// Two-letter leading segment is root-anchored, not a drive.
|
||||
assert_eq!(msys_to_windows("/cc/x"), "/cc/x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_slash_inputs_are_left_untranslated() {
|
||||
assert_eq!(msys_to_windows("/"), "/");
|
||||
assert_eq!(msys_to_windows("//"), "//");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cygdrive_path_resolves_against_drive_not_doubled() {
|
||||
// Integration: a cygdrive path resolves to a real Windows-absolute
|
||||
// candidate (the system root always exists), proving the drive is
|
||||
// not doubled into C:/c/... as the pre-fix bug did.
|
||||
let resolved = resolve_path(Path::new(r"C:\does\not\matter"), "/c/Windows")
|
||||
.expect("cygdrive path resolves");
|
||||
assert!(resolved
|
||||
.to_string_lossy()
|
||||
.to_lowercase()
|
||||
.contains(r"c:\windows"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
use crate::shell::SharedState;
|
||||
use rmcp::ErrorData;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
const DEFAULT_LIMIT: usize = 2000;
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct ReadFileParams {
|
||||
/// File path (absolute or relative to workdir).
|
||||
pub path: String,
|
||||
/// 0-based line offset to start reading from. Defaults to 0.
|
||||
#[serde(default)]
|
||||
pub offset: Option<usize>,
|
||||
/// Maximum number of lines to return. Defaults to 2000.
|
||||
#[serde(default)]
|
||||
pub limit: Option<usize>,
|
||||
/// Workspace root for relative path resolution. Defaults to server cwd.
|
||||
#[serde(default)]
|
||||
pub workdir: Option<String>,
|
||||
}
|
||||
|
||||
pub fn run(state: &SharedState, p: ReadFileParams) -> Result<String, ErrorData> {
|
||||
let (_target, content) = crate::paths::read_text_file(state, &p.path, p.workdir.as_deref())?;
|
||||
|
||||
let all_lines: Vec<&str> = content.lines().collect();
|
||||
let total = all_lines.len();
|
||||
|
||||
if total == 0 {
|
||||
return Ok(format!("{} is empty (0 lines)", p.path));
|
||||
}
|
||||
|
||||
let offset = p.offset.unwrap_or(0);
|
||||
let limit = p.limit.unwrap_or(DEFAULT_LIMIT);
|
||||
|
||||
let slice = &all_lines[offset.min(total)..];
|
||||
let slice = &slice[..slice.len().min(limit)];
|
||||
|
||||
if slice.is_empty() {
|
||||
return Ok(format!(
|
||||
"{} (no lines in range, file has {} lines)",
|
||||
p.path, total
|
||||
));
|
||||
}
|
||||
|
||||
// 1-based line numbers in the output.
|
||||
let start_line = offset + 1;
|
||||
let end_line = offset + slice.len();
|
||||
|
||||
let mut out = format!(
|
||||
"{} (lines {}-{} of {})\n",
|
||||
p.path, start_line, end_line, total
|
||||
);
|
||||
for (i, line) in slice.iter().enumerate() {
|
||||
let line_number = offset + i + 1;
|
||||
out.push_str(&format!("{line_number}:{line}\n"));
|
||||
}
|
||||
|
||||
if end_line < total {
|
||||
out.push_str(&format!(
|
||||
"[showing lines {start_line}-{end_line} of {total}; use offset={end_line} to continue]\n"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn make_state(cwd: &std::path::Path) -> SharedState {
|
||||
let shim = crate::shim::Shim::install().expect("shim install");
|
||||
SharedState::new(cwd.to_path_buf(), shim).expect("state new")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_basic() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let f = dir.path().join("basic.txt");
|
||||
fs::write(&f, "line1\nline2\nline3\nline4\nline5\n").expect("write");
|
||||
let state = make_state(dir.path());
|
||||
let p = ReadFileParams {
|
||||
path: "basic.txt".into(),
|
||||
offset: None,
|
||||
limit: None,
|
||||
workdir: Some(dir.path().display().to_string()),
|
||||
};
|
||||
let out = run(&state, p).expect("ok");
|
||||
assert!(out.contains("lines 1-5 of 5"), "out: {out}");
|
||||
assert!(out.contains("1:line1"), "out: {out}");
|
||||
assert!(out.contains("2:line2"), "out: {out}");
|
||||
assert!(out.contains("3:line3"), "out: {out}");
|
||||
assert!(out.contains("4:line4"), "out: {out}");
|
||||
assert!(out.contains("5:line5"), "out: {out}");
|
||||
assert!(
|
||||
!out.contains("[showing lines"),
|
||||
"full file should have no truncation footer: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_offset_limit() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let f = dir.path().join("ten.txt");
|
||||
let contents: String = (1..=10).map(|i| format!("line{i}\n")).collect();
|
||||
fs::write(&f, &contents).expect("write");
|
||||
let state = make_state(dir.path());
|
||||
let p = ReadFileParams {
|
||||
path: "ten.txt".into(),
|
||||
offset: Some(3),
|
||||
limit: Some(2),
|
||||
workdir: Some(dir.path().display().to_string()),
|
||||
};
|
||||
let out = run(&state, p).expect("ok");
|
||||
assert!(out.contains("lines 4-5 of 10"), "out: {out}");
|
||||
assert!(out.contains("4:line4"), "out: {out}");
|
||||
assert!(out.contains("5:line5"), "out: {out}");
|
||||
assert!(
|
||||
out.contains("[showing lines 4-5 of 10; use offset=5 to continue]"),
|
||||
"out: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_empty_file() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let f = dir.path().join("empty.txt");
|
||||
fs::write(&f, b"").expect("write");
|
||||
let state = make_state(dir.path());
|
||||
let p = ReadFileParams {
|
||||
path: "empty.txt".into(),
|
||||
offset: None,
|
||||
limit: None,
|
||||
workdir: Some(dir.path().display().to_string()),
|
||||
};
|
||||
let out = run(&state, p).expect("ok");
|
||||
assert!(out.contains("is empty (0 lines)"), "out: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_allows_absolute_path() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
// A real file in a SECOND tempdir, genuinely outside the workspace
|
||||
// root — proves absolute paths beyond workdir resolve, without a
|
||||
// Unix-only system path like /etc/hosts (which is C:\etc\hosts on
|
||||
// Windows and does not exist).
|
||||
let outside = tempdir().expect("tempdir");
|
||||
let target = outside.path().join("outside.txt");
|
||||
fs::write(&target, b"localhost").expect("write");
|
||||
let state = make_state(dir.path());
|
||||
let p = ReadFileParams {
|
||||
path: target.display().to_string(),
|
||||
offset: None,
|
||||
limit: None,
|
||||
workdir: Some(dir.path().display().to_string()),
|
||||
};
|
||||
let out = run(&state, p).expect("ok");
|
||||
assert!(
|
||||
out.contains("localhost"),
|
||||
"expected out-of-workspace file content, got: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_rejects_too_large() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let f = dir.path().join("big.bin");
|
||||
let big = vec![b'a'; (10 * 1024 * 1024_usize) + 1024];
|
||||
fs::write(&f, &big).expect("write");
|
||||
let state = make_state(dir.path());
|
||||
let p = ReadFileParams {
|
||||
path: "big.bin".into(),
|
||||
offset: None,
|
||||
limit: None,
|
||||
workdir: Some(dir.path().display().to_string()),
|
||||
};
|
||||
let err = run(&state, p).unwrap_err();
|
||||
let msg = format!("{err:?}");
|
||||
assert!(msg.contains("too large"), "msg: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_offset_past_end() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let f = dir.path().join("short.txt");
|
||||
fs::write(&f, "line1\nline2\n").expect("write");
|
||||
let state = make_state(dir.path());
|
||||
let p = ReadFileParams {
|
||||
path: "short.txt".into(),
|
||||
offset: Some(100),
|
||||
limit: None,
|
||||
workdir: Some(dir.path().display().to_string()),
|
||||
};
|
||||
let out = run(&state, p).expect("ok");
|
||||
assert!(out.contains("no lines in range"), "out: {out}");
|
||||
assert!(out.contains("file has 2 lines"), "out: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_limit_zero() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let f = dir.path().join("some.txt");
|
||||
fs::write(&f, "line1\nline2\n").expect("write");
|
||||
let state = make_state(dir.path());
|
||||
let p = ReadFileParams {
|
||||
path: "some.txt".into(),
|
||||
offset: None,
|
||||
limit: Some(0),
|
||||
workdir: Some(dir.path().display().to_string()),
|
||||
};
|
||||
let out = run(&state, p).expect("ok");
|
||||
assert!(out.contains("no lines in range"), "out: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_file_without_trailing_newline() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let f = dir.path().join("notrail.txt");
|
||||
fs::write(&f, "line1\nline2\nline3").expect("write");
|
||||
let state = make_state(dir.path());
|
||||
let p = ReadFileParams {
|
||||
path: "notrail.txt".into(),
|
||||
offset: None,
|
||||
limit: None,
|
||||
workdir: Some(dir.path().display().to_string()),
|
||||
};
|
||||
let out = run(&state, p).expect("ok");
|
||||
assert!(out.contains("lines 1-3 of 3"), "out: {out}");
|
||||
assert!(out.contains("3:line3"), "out: {out}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
const MAX_LINE_BYTES: usize = 1024 * 1024; // 1MB per line — skip files with longer lines (likely binary)
|
||||
const MAX_OUTPUT_BYTES: usize = 50 * 1024;
|
||||
const MAX_OUTPUT_LINES: usize = 2000;
|
||||
const MAX_CONTEXT: usize = 100;
|
||||
const MAX_WALK_DEPTH: usize = 50;
|
||||
|
||||
pub fn run(args: Vec<String>) -> i32 {
|
||||
if let Some(code) = try_system_rg(&args) {
|
||||
return code;
|
||||
}
|
||||
fallback(args)
|
||||
}
|
||||
|
||||
fn try_system_rg(args: &[String]) -> Option<i32> {
|
||||
let self_exe = std::env::current_exe().ok()?;
|
||||
let self_canon = std::fs::canonicalize(&self_exe).ok()?;
|
||||
let cleaned_path = clean_path(&self_canon);
|
||||
let candidate = which_rg(&cleaned_path)?;
|
||||
|
||||
let mut cmd = Command::new(&candidate);
|
||||
cmd.args(args).env("PATH", &cleaned_path);
|
||||
crate::configure_no_window(&mut cmd);
|
||||
let status = cmd.status().ok()?;
|
||||
Some(status.code().unwrap_or(2))
|
||||
}
|
||||
|
||||
fn clean_path(self_canon: &Path) -> String {
|
||||
let original = std::env::var("PATH").unwrap_or_default();
|
||||
original
|
||||
.split(':')
|
||||
.filter(|dir| {
|
||||
if dir.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let candidate = Path::new(dir).join("rg");
|
||||
match std::fs::canonicalize(&candidate) {
|
||||
Ok(c) => c != *self_canon,
|
||||
Err(_) => true,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(":")
|
||||
}
|
||||
|
||||
fn which_rg(path: &str) -> Option<PathBuf> {
|
||||
for dir in path.split(':') {
|
||||
if dir.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let candidate = Path::new(dir).join("rg");
|
||||
if candidate.is_file() && is_executable(&candidate) {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_executable(p: &Path) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::metadata(p)
|
||||
.map(|m| m.permissions().mode() & 0o111 != 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn is_executable(_p: &Path) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg_attr(test, derive(Debug))]
|
||||
struct RgArgs {
|
||||
pattern: Option<String>,
|
||||
paths: Vec<PathBuf>,
|
||||
line_numbers: bool,
|
||||
ignore_case: bool,
|
||||
files_only: bool,
|
||||
list_files_with_matches: bool,
|
||||
context: usize,
|
||||
glob: Option<String>,
|
||||
}
|
||||
|
||||
fn parse(args: Vec<String>) -> Result<RgArgs, String> {
|
||||
let mut out = RgArgs {
|
||||
pattern: None,
|
||||
paths: Vec::new(),
|
||||
line_numbers: false,
|
||||
ignore_case: false,
|
||||
files_only: false,
|
||||
list_files_with_matches: false,
|
||||
context: 0,
|
||||
glob: None,
|
||||
};
|
||||
let mut iter = args.into_iter();
|
||||
let mut positional: Vec<String> = Vec::new();
|
||||
while let Some(a) = iter.next() {
|
||||
match a.as_str() {
|
||||
"--files" => out.files_only = true,
|
||||
"-n" | "--line-number" => out.line_numbers = true,
|
||||
"-i" | "--ignore-case" => out.ignore_case = true,
|
||||
"-l" | "--files-with-matches" => out.list_files_with_matches = true,
|
||||
"-C" | "--context" => {
|
||||
let n = iter.next().ok_or("missing value for -C")?;
|
||||
let parsed: usize = n.parse().map_err(|_| format!("bad -C value: {n}"))?;
|
||||
out.context = parsed.min(MAX_CONTEXT);
|
||||
}
|
||||
"-g" | "--glob" => {
|
||||
out.glob = Some(iter.next().ok_or("missing value for -g")?);
|
||||
}
|
||||
"--" => positional.extend(iter.by_ref()),
|
||||
s if s.starts_with('-') && s.len() > 1 => {
|
||||
return Err(format!("unsupported flag (fallback rg): {s}"));
|
||||
}
|
||||
_ => positional.push(a),
|
||||
}
|
||||
}
|
||||
if out.files_only {
|
||||
out.paths = positional.into_iter().map(PathBuf::from).collect();
|
||||
if out.paths.is_empty() {
|
||||
out.paths.push(PathBuf::from("."));
|
||||
}
|
||||
} else {
|
||||
let mut it = positional.into_iter();
|
||||
out.pattern = it.next();
|
||||
out.paths = it.map(PathBuf::from).collect();
|
||||
if out.paths.is_empty() {
|
||||
out.paths.push(PathBuf::from("."));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
struct CappedSink {
|
||||
bytes: usize,
|
||||
lines: usize,
|
||||
capped: bool,
|
||||
}
|
||||
|
||||
impl CappedSink {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
bytes: 0,
|
||||
lines: 0,
|
||||
capped: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn writeln(&mut self, s: &str) {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
let next = self.bytes.saturating_add(s.len()).saturating_add(1);
|
||||
if next > MAX_OUTPUT_BYTES || self.lines >= MAX_OUTPUT_LINES {
|
||||
self.capped = true;
|
||||
tracing::warn!("rg (fallback): output capped at {MAX_OUTPUT_BYTES} bytes / {MAX_OUTPUT_LINES} lines");
|
||||
return;
|
||||
}
|
||||
println!("{s}");
|
||||
self.bytes = next;
|
||||
self.lines += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback(args: Vec<String>) -> i32 {
|
||||
let opts = match parse(args) {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
tracing::error!("rg (fallback): {e}");
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
let mut sink = CappedSink::new();
|
||||
let mut found = false;
|
||||
let mut printed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
|
||||
|
||||
if opts.files_only {
|
||||
for root in &opts.paths {
|
||||
walk(root, &opts, &mut |p| {
|
||||
sink.writeln(&p.display().to_string());
|
||||
found = true;
|
||||
!sink.capped
|
||||
});
|
||||
if sink.capped {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return if found { 0 } else { 1 };
|
||||
}
|
||||
|
||||
let pattern = match &opts.pattern {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
tracing::error!("rg (fallback): missing PATTERN");
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
let needle = if opts.ignore_case {
|
||||
pattern.to_lowercase()
|
||||
} else {
|
||||
pattern
|
||||
};
|
||||
|
||||
for root in &opts.paths {
|
||||
walk(root, &opts, &mut |path| {
|
||||
if sink.capped {
|
||||
return false;
|
||||
}
|
||||
if scan_file(path, &needle, &opts, &mut sink, &mut printed) {
|
||||
found = true;
|
||||
}
|
||||
!sink.capped
|
||||
});
|
||||
if sink.capped {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if found {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn read_bounded_line(reader: &mut impl BufRead, max: usize) -> Option<Result<String, ()>> {
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let available = match reader.fill_buf() {
|
||||
Ok([]) => {
|
||||
if buf.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return match String::from_utf8(buf) {
|
||||
Ok(s) => Some(Ok(s)),
|
||||
Err(_) => Some(Err(())),
|
||||
};
|
||||
}
|
||||
Ok(b) => b,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let take = available
|
||||
.iter()
|
||||
.position(|b| *b == b'\n')
|
||||
.map_or(available.len(), |i| i + 1);
|
||||
if buf.len() + take > max {
|
||||
return Some(Err(()));
|
||||
}
|
||||
buf.extend_from_slice(&available[..take]);
|
||||
reader.consume(take);
|
||||
if buf.ends_with(b"\n") {
|
||||
buf.pop();
|
||||
return match String::from_utf8(buf) {
|
||||
Ok(s) => Some(Ok(s)),
|
||||
Err(_) => Some(Err(())),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_file(
|
||||
path: &Path,
|
||||
needle: &str,
|
||||
opts: &RgArgs,
|
||||
sink: &mut CappedSink,
|
||||
printed: &mut std::collections::HashSet<PathBuf>,
|
||||
) -> bool {
|
||||
let file = match std::fs::File::open(path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let cap = opts.context;
|
||||
let mut ring: std::collections::VecDeque<String> =
|
||||
std::collections::VecDeque::with_capacity(cap);
|
||||
let mut tail = 0usize;
|
||||
let mut last: Option<usize> = None;
|
||||
let mut found = false;
|
||||
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut idx = 0usize;
|
||||
loop {
|
||||
let line = match read_bounded_line(&mut reader, MAX_LINE_BYTES) {
|
||||
None => break,
|
||||
Some(Err(())) => return found,
|
||||
Some(Ok(l)) => l,
|
||||
};
|
||||
if sink.capped {
|
||||
return found;
|
||||
}
|
||||
let is_match = if opts.ignore_case {
|
||||
line.to_lowercase().contains(needle)
|
||||
} else {
|
||||
line.contains(needle)
|
||||
};
|
||||
|
||||
if is_match {
|
||||
found = true;
|
||||
if opts.list_files_with_matches {
|
||||
if printed.insert(path.to_path_buf()) {
|
||||
sink.writeln(&path.display().to_string());
|
||||
}
|
||||
return found;
|
||||
}
|
||||
let ring_start = idx.saturating_sub(ring.len());
|
||||
for (offset, prev) in ring.iter().enumerate() {
|
||||
let li = ring_start + offset;
|
||||
if last.is_none_or(|l| li > l) {
|
||||
emit_line(path, li, prev, opts, sink);
|
||||
last = Some(li);
|
||||
}
|
||||
}
|
||||
if last.is_none_or(|l| idx > l) {
|
||||
emit_line(path, idx, &line, opts, sink);
|
||||
last = Some(idx);
|
||||
}
|
||||
tail = cap;
|
||||
} else if tail > 0 {
|
||||
emit_line(path, idx, &line, opts, sink);
|
||||
last = Some(idx);
|
||||
tail -= 1;
|
||||
}
|
||||
|
||||
if cap > 0 {
|
||||
if ring.len() == cap {
|
||||
ring.pop_front();
|
||||
}
|
||||
ring.push_back(line);
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn emit_line(path: &Path, line_idx: usize, line: &str, opts: &RgArgs, sink: &mut CappedSink) {
|
||||
let prefix = if opts.line_numbers {
|
||||
format!("{}:{}:", path.display(), line_idx + 1)
|
||||
} else {
|
||||
format!("{}:", path.display())
|
||||
};
|
||||
sink.writeln(&format!("{prefix}{line}"));
|
||||
}
|
||||
|
||||
fn walk(root: &Path, opts: &RgArgs, on_file: &mut dyn FnMut(&Path) -> bool) {
|
||||
if root.is_file() {
|
||||
if accept(root, opts) {
|
||||
on_file(root);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut stack: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)];
|
||||
while let Some((dir, depth)) = stack.pop() {
|
||||
let entries = match std::fs::read_dir(&dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let ft = match entry.file_type() {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if ft.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
if ft.is_dir() {
|
||||
if matches!(name, "target" | "node_modules" | "dist" | "build") {
|
||||
continue;
|
||||
}
|
||||
if depth < MAX_WALK_DEPTH {
|
||||
stack.push((path, depth + 1));
|
||||
}
|
||||
} else if ft.is_file() && accept(&path, opts) && !on_file(&path) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn accept(path: &Path, opts: &RgArgs) -> bool {
|
||||
match &opts.glob {
|
||||
None => true,
|
||||
Some(g) => glob_match(g, path),
|
||||
}
|
||||
}
|
||||
|
||||
fn glob_match(pattern: &str, path: &Path) -> bool {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
let full = path.to_string_lossy();
|
||||
simple_glob(pattern, name) || simple_glob(pattern, &full)
|
||||
}
|
||||
|
||||
fn simple_glob(pattern: &str, text: &str) -> bool {
|
||||
let p: Vec<char> = pattern.chars().collect();
|
||||
let t: Vec<char> = text.chars().collect();
|
||||
|
||||
let (mut pi, mut ti) = (0usize, 0usize);
|
||||
let (mut star_pi, mut star_ti) = (usize::MAX, 0usize);
|
||||
|
||||
while ti < t.len() {
|
||||
if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
|
||||
pi += 1;
|
||||
ti += 1;
|
||||
} else if pi < p.len() && p[pi] == '*' {
|
||||
star_pi = pi;
|
||||
star_ti = ti;
|
||||
pi += 1;
|
||||
} else if star_pi != usize::MAX {
|
||||
pi = star_pi + 1;
|
||||
star_ti += 1;
|
||||
ti = star_ti;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
while pi < p.len() && p[pi] == '*' {
|
||||
pi += 1;
|
||||
}
|
||||
|
||||
pi == p.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_basic_pattern_and_path() {
|
||||
let opts = parse(vec!["-n".into(), "needle".into(), "src".into()]).expect("parse");
|
||||
assert!(opts.line_numbers);
|
||||
assert_eq!(opts.pattern.as_deref(), Some("needle"));
|
||||
assert_eq!(opts.paths, vec![PathBuf::from("src")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_files_only() {
|
||||
let opts = parse(vec!["--files".into(), ".".into()]).expect("parse");
|
||||
assert!(opts.files_only);
|
||||
assert_eq!(opts.paths, vec![PathBuf::from(".")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_unknown_flag() {
|
||||
let err = parse(vec!["-Z".into(), "x".into()]).unwrap_err();
|
||||
assert!(err.contains("unsupported flag"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capped_sink_stops_at_byte_limit() {
|
||||
let mut s = CappedSink::new();
|
||||
// Below cap: accepted.
|
||||
s.bytes = MAX_OUTPUT_BYTES - 10;
|
||||
s.writeln("12345");
|
||||
assert!(!s.capped);
|
||||
// This pushes us over.
|
||||
s.writeln("x".repeat(20).as_str());
|
||||
assert!(s.capped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_matches_simple_patterns() {
|
||||
assert!(simple_glob("*.rs", "main.rs"));
|
||||
assert!(simple_glob("src/*.rs", "src/main.rs"));
|
||||
assert!(!simple_glob("*.rs", "main.txt"));
|
||||
assert!(simple_glob("a?c", "abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_finds_match_in_file() {
|
||||
// End-to-end: create a file, run the fallback parser, scan it.
|
||||
// We can't easily capture stdout here, so we verify scan_file
|
||||
// returns true (match found).
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let f = dir.path().join("a.txt");
|
||||
std::fs::write(&f, "alpha\nNEEDLE\nbeta\n").expect("write");
|
||||
let opts = parse(vec!["NEEDLE".into(), f.display().to_string()]).expect("parse");
|
||||
let mut sink = CappedSink::new();
|
||||
let mut printed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
|
||||
let found = scan_file(&f, "NEEDLE", &opts, &mut sink, &mut printed);
|
||||
assert!(found, "expected NEEDLE to be found");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,695 @@
|
||||
use nostr::ToBech32;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tempfile::TempDir;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
/// Session-scoped shim directory providing tools and git config to shell children.
|
||||
///
|
||||
/// On install:
|
||||
/// 1. Creates a 0700 tempdir with symlinks back to our binary (multicall)
|
||||
/// 2. If `NOSTR_PRIVATE_KEY` is set: writes a 0600 keyfile, derives the pubkey,
|
||||
/// builds ephemeral `GIT_CONFIG_*` env vars, then removes the env var
|
||||
/// 3. Prepends the shim dir to PATH
|
||||
///
|
||||
/// Shell children receive `path_env`, `git_env`, and `BUZZ_PRIVATE_KEY` (for
|
||||
/// the buzz CLI). `NOSTR_PRIVATE_KEY` is removed from the process env after
|
||||
/// the keyfile is written — git helpers read from the keyfile only.
|
||||
/// Cleaned up on drop (TempDir).
|
||||
pub struct Shim {
|
||||
_dir: TempDir,
|
||||
pub path_env: String,
|
||||
pub git_env: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Shim {
|
||||
pub fn install() -> std::io::Result<Self> {
|
||||
let dir = tempfile::Builder::new().prefix("buzz-dev-mcp-").tempdir()?;
|
||||
set_owner_only(dir.path())?;
|
||||
|
||||
let self_exe = std::env::current_exe()?;
|
||||
|
||||
// Multicall symlinks — all resolve back to this binary.
|
||||
for name in [
|
||||
"rg",
|
||||
"tree",
|
||||
"buzz",
|
||||
"git-credential-nostr",
|
||||
"git-sign-nostr",
|
||||
] {
|
||||
symlink(&self_exe, &dir.path().join(name))?;
|
||||
}
|
||||
|
||||
let original = std::env::var_os("PATH").unwrap_or_default();
|
||||
let mut entries = vec![PathBuf::from(dir.path())];
|
||||
entries.extend(std::env::split_paths(&original));
|
||||
// join_paths uses the platform separator (':' on Unix, ';' on Windows).
|
||||
let path_env = std::env::join_paths(entries)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
// Read and unconditionally remove NOSTR_PRIVATE_KEY from this process's
|
||||
// env. The key must never leak to child processes regardless of whether
|
||||
// keyfile creation succeeds.
|
||||
let mut nostr_key = std::env::var("NOSTR_PRIVATE_KEY").ok();
|
||||
std::env::remove_var("NOSTR_PRIVATE_KEY");
|
||||
|
||||
// Ephemeral git config: write key to 0600 keyfile, derive pubkey, build
|
||||
// GIT_CONFIG_* env vars for nostr auth + signing.
|
||||
let git_env = match nostr_key
|
||||
.as_deref()
|
||||
.and_then(|k| write_keyfile(dir.path(), k))
|
||||
{
|
||||
Some(info) => build_git_env(&info),
|
||||
None => Vec::new(),
|
||||
};
|
||||
if let Some(ref mut k) = nostr_key {
|
||||
k.zeroize();
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
_dir: dir,
|
||||
path_env,
|
||||
git_env,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct KeyInfo {
|
||||
keyfile_path: String,
|
||||
pubkey_hex: String,
|
||||
npub: String,
|
||||
}
|
||||
|
||||
/// Write the nostr private key to an owner-only file in the shim dir.
|
||||
/// Returns key metadata or None if key is empty/invalid.
|
||||
/// Warns to stderr if the key is invalid (operator mistake).
|
||||
fn write_keyfile(shim_dir: &Path, raw: &str) -> Option<KeyInfo> {
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let keys = match nostr::Keys::parse(raw) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"buzz-dev-mcp: warning: NOSTR_PRIVATE_KEY is set but invalid ({e}); \
|
||||
git auth/signing will be disabled"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let pubkey_hex = keys.public_key().to_hex();
|
||||
let npub = keys
|
||||
.public_key()
|
||||
.to_bech32()
|
||||
.unwrap_or_else(|_| pubkey_hex.clone());
|
||||
|
||||
let keyfile = shim_dir.join(".nostr-key");
|
||||
if write_keyfile_atomic(&keyfile, raw.as_bytes()).is_err() {
|
||||
eprintln!(
|
||||
"buzz-dev-mcp: warning: failed to write nostr keyfile; git auth/signing disabled"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let keyfile_path = match keyfile.to_str() {
|
||||
Some(s) => s.to_owned(),
|
||||
None => {
|
||||
eprintln!(
|
||||
"buzz-dev-mcp: warning: tempdir path is not valid UTF-8; git auth/signing disabled"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(KeyInfo {
|
||||
keyfile_path,
|
||||
pubkey_hex,
|
||||
npub,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write `data` to `path` with 0600 permissions set at creation time via
|
||||
/// `OpenOptions::mode()` (no window where the file is world-readable).
|
||||
/// Non-Unix: plain write — acceptable inside our 0700 tempdir.
|
||||
#[cfg(unix)]
|
||||
fn write_keyfile_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(path)?;
|
||||
f.write_all(data)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn write_keyfile_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
std::fs::write(path, data)
|
||||
}
|
||||
|
||||
/// Derive a NIP-05-style email from the pubkey and relay URL.
|
||||
/// Format: `<hex_pubkey>@<relay_host>` (e.g., `ab12...cd@relay.buzz.dev`).
|
||||
/// Falls back to `<hex_pubkey>@buzz` if no relay URL is configured.
|
||||
fn derive_git_email(pubkey_hex: &str) -> String {
|
||||
let host = std::env::var("BUZZ_RELAY_URL")
|
||||
.ok()
|
||||
.and_then(|url| {
|
||||
// Strip scheme, port, and trailing paths
|
||||
let stripped = url
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| url.strip_prefix("http://"))
|
||||
.or_else(|| url.strip_prefix("wss://"))
|
||||
.or_else(|| url.strip_prefix("ws://"))
|
||||
.unwrap_or(&url);
|
||||
let host_port = stripped.split('/').next()?;
|
||||
// Strip port number (e.g., "localhost:3000" → "localhost")
|
||||
Some(host_port.split(':').next().unwrap_or(host_port).to_owned())
|
||||
})
|
||||
.filter(|h| !h.is_empty() && !h.starts_with("localhost") && !h.starts_with("127."))
|
||||
.unwrap_or_else(|| "buzz".to_owned());
|
||||
format!("{pubkey_hex}@{host}")
|
||||
}
|
||||
|
||||
/// Stable identity contract for git attribution: the bare agent display name,
|
||||
/// never channel-qualified, safe to embed in commit history.
|
||||
///
|
||||
/// Deliberately distinct from `BUZZ_ACP_SESSION_TITLE`, which is per-session UI
|
||||
/// chrome and may be composed (`Agent · #channel`) by consumers. Commits
|
||||
/// outlive sessions, so git attribution must not follow a mutable title.
|
||||
///
|
||||
/// Nothing writes this yet — when unset, [`build_git_env`] falls back to the
|
||||
/// npub, which is byte-for-byte today's behavior.
|
||||
const DISPLAY_NAME_ENV_VAR: &str = "BUZZ_ACP_DISPLAY_NAME";
|
||||
|
||||
/// Max characters in a git author name. Nostr display names are unbounded.
|
||||
const MAX_GIT_USER_NAME_CHARS: usize = 80;
|
||||
|
||||
/// Characters git's `ident.c` treats as "crud": stripped from both ends of a
|
||||
/// name, and — when a name is *nothing but* these — rejected outright with
|
||||
/// `fatal: name consists only of disallowed characters`.
|
||||
///
|
||||
/// Verified empirically against git 2.54.0 by committing with each ASCII byte
|
||||
/// 32..=126 as the entire `user.name`: exactly space, `"`, `'`, `,`, `:`, `;`,
|
||||
/// `<`, `>`, and `\` abort. Control characters abort too (the predicate is
|
||||
/// `c <= 32`). Note `.` is *not* crud in this version despite older lore.
|
||||
fn is_git_crud(c: char) -> bool {
|
||||
c <= ' ' || matches!(c, '"' | '\'' | ',' | ':' | ';' | '<' | '>' | '\\')
|
||||
}
|
||||
|
||||
/// Characters in Unicode general category `Cf` (format): zero-width space and
|
||||
/// joiners, bidi embedding/override marks, invisible math operators, interlinear
|
||||
/// annotations, and tag characters.
|
||||
///
|
||||
/// `char::is_control` covers only `Cc`, so every one of these survives it — and
|
||||
/// none is whitespace or [`is_git_crud`]. A display name of nothing but U+200B
|
||||
/// ZERO WIDTH SPACE would therefore satisfy the "at least one non-crud
|
||||
/// character" gate and hand git a visually blank author instead of falling back
|
||||
/// to the npub. An embedded U+202E RIGHT-TO-LEFT OVERRIDE is worse: it makes a
|
||||
/// commit's persisted author line render as something other than what it says,
|
||||
/// the same confusion the angle-bracket filter exists to prevent.
|
||||
///
|
||||
/// The whole category is rejected rather than the two known-bad marks, because
|
||||
/// the boundary that matters is "invisible or reorders text", not "the codepoint
|
||||
/// someone thought of". Ranges transcribed from the UCD's
|
||||
/// `DerivedGeneralCategory.txt` (17.0.0) and independently cross-checked against
|
||||
/// Python's `unicodedata` (16.0.0); both yield exactly these 21 ranges. Inlined
|
||||
/// rather than taking a Unicode-tables dependency for one predicate.
|
||||
fn is_unicode_format(c: char) -> bool {
|
||||
matches!(c,
|
||||
'\u{00AD}'
|
||||
| '\u{0600}'..='\u{0605}'
|
||||
| '\u{061C}'
|
||||
| '\u{06DD}'
|
||||
| '\u{070F}'
|
||||
| '\u{0890}'..='\u{0891}'
|
||||
| '\u{08E2}'
|
||||
| '\u{180E}'
|
||||
| '\u{200B}'..='\u{200F}'
|
||||
| '\u{202A}'..='\u{202E}'
|
||||
| '\u{2060}'..='\u{2064}'
|
||||
| '\u{2066}'..='\u{206F}'
|
||||
| '\u{FEFF}'
|
||||
| '\u{FFF9}'..='\u{FFFB}'
|
||||
| '\u{110BD}'
|
||||
| '\u{110CD}'
|
||||
| '\u{13430}'..='\u{1343F}'
|
||||
| '\u{1BCA0}'..='\u{1BCA3}'
|
||||
| '\u{1D173}'..='\u{1D17A}'
|
||||
| '\u{E0001}'
|
||||
| '\u{E0020}'..='\u{E007F}'
|
||||
)
|
||||
}
|
||||
|
||||
/// Normalize a Buzz display name into a git author name, or `None` to fall
|
||||
/// back to the npub.
|
||||
///
|
||||
/// Strips control and Unicode format characters plus angle brackets, collapses
|
||||
/// whitespace runs, trims, and caps at [`MAX_GIT_USER_NAME_CHARS`] by `chars()`
|
||||
/// so a multi-byte name cannot be split mid-UTF-8. Angle brackets go because git
|
||||
/// silently drops them rather than erroring — `Duncan <evil@x.com>` would
|
||||
/// render as `Duncan evil@x.com <hex@relay>`, which forges nothing but reads as
|
||||
/// though it might.
|
||||
///
|
||||
/// Returns `None` unless at least one non-crud character survives. A bare
|
||||
/// emptiness check is not sufficient: git rejects a name built only of crud,
|
||||
/// so a display name of `;;` or `""` would abort **every commit** the agent
|
||||
/// makes. Falling back to the npub keeps the agent able to commit.
|
||||
fn sanitize_git_user_name(raw: &str) -> Option<String> {
|
||||
let collapsed = raw
|
||||
.split_whitespace()
|
||||
.map(|word| {
|
||||
word.chars()
|
||||
.filter(|c| !c.is_control() && !is_unicode_format(*c) && *c != '<' && *c != '>')
|
||||
.collect::<String>()
|
||||
})
|
||||
.filter(|word| !word.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let name: String = collapsed
|
||||
.chars()
|
||||
.take(MAX_GIT_USER_NAME_CHARS)
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_string();
|
||||
name.chars().any(|c| !is_git_crud(c)).then_some(name)
|
||||
}
|
||||
|
||||
/// Build GIT_CONFIG_COUNT/KEY/VALUE env vars for ephemeral nostr git config.
|
||||
/// Composes with any existing GIT_CONFIG_COUNT in the environment. When launched
|
||||
/// via buzz-agent (which clears env), the base is always 0 — composition only
|
||||
/// matters when dev-mcp is run directly with pre-existing GIT_CONFIG vars.
|
||||
fn build_git_env(info: &KeyInfo) -> Vec<(String, String)> {
|
||||
let email = derive_git_email(&info.pubkey_hex);
|
||||
// Display name for humans reading `git log`; the pubkey stays in the email,
|
||||
// which is what NIP-98 auth, NIP-GS signing, and contributor matching key on.
|
||||
let user_name = std::env::var(DISPLAY_NAME_ENV_VAR)
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(sanitize_git_user_name)
|
||||
.unwrap_or_else(|| info.npub.clone());
|
||||
let entries: Vec<(&str, String)> = vec![
|
||||
// Identity — Buzz display name (npub fallback), NIP-05-style email
|
||||
("user.name", user_name),
|
||||
("user.email", email),
|
||||
// Nostr credential helper is additive — it silently declines non-Buzz
|
||||
// remotes (exits 0, no credential), so git falls through to system
|
||||
// helpers (osxkeychain, store, etc.) for GitHub/GitLab/etc.
|
||||
("credential.helper", "nostr".into()),
|
||||
// Required: Buzz relay verifies NIP-98 against the full repo-root URL.
|
||||
// Without useHttpPath, git only passes the host and auth is rejected.
|
||||
("credential.useHttpPath", "true".into()),
|
||||
("nostr.keyfile", info.keyfile_path.clone()),
|
||||
("gpg.format", "x509".into()),
|
||||
("gpg.x509.program", "git-sign-nostr".into()),
|
||||
("commit.gpgSign", "true".into()),
|
||||
("tag.gpgSign", "true".into()),
|
||||
("user.signingkey", info.pubkey_hex.clone()),
|
||||
];
|
||||
|
||||
// Compose with existing GIT_CONFIG_COUNT — don't clobber caller's config.
|
||||
let base: usize = std::env::var("GIT_CONFIG_COUNT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut env = Vec::with_capacity(entries.len() * 2 + 1);
|
||||
env.push((
|
||||
"GIT_CONFIG_COUNT".into(),
|
||||
(base + entries.len()).to_string(),
|
||||
));
|
||||
for (i, (key, val)) in entries.iter().enumerate() {
|
||||
let idx = base + i;
|
||||
env.push((format!("GIT_CONFIG_KEY_{idx}"), key.to_string()));
|
||||
env.push((format!("GIT_CONFIG_VALUE_{idx}"), val.to_string()));
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_owner_only(path: &Path) -> std::io::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(path)?.permissions();
|
||||
perms.set_mode(0o700);
|
||||
std::fs::set_permissions(path, perms)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_owner_only(_: &Path) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
std::os::unix::fs::symlink(src, dst)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
// No symlinks without elevation on Windows; copy instead. The target needs
|
||||
// a .exe extension or PATH lookup (via PATHEXT) won't treat it as runnable.
|
||||
let dst = dst.with_extension("exe");
|
||||
std::fs::copy(src, dst).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn artifact_dir(session_root: &Path) -> PathBuf {
|
||||
let p = session_root.join("artifacts");
|
||||
let _ = std::fs::create_dir_all(&p);
|
||||
p
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod git_user_name_tests {
|
||||
use super::{
|
||||
build_git_env, is_git_crud, is_unicode_format, sanitize_git_user_name, KeyInfo,
|
||||
MAX_GIT_USER_NAME_CHARS,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Env-var-touching tests must run serially — env vars are process-global.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
const PUBKEY_HEX: &str = "dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95";
|
||||
const NPUB: &str = "npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7";
|
||||
|
||||
fn key_info() -> KeyInfo {
|
||||
KeyInfo {
|
||||
keyfile_path: "/tmp/.nostr-key".into(),
|
||||
pubkey_hex: PUBKEY_HEX.into(),
|
||||
npub: NPUB.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a git config value back out of the flat GIT_CONFIG_KEY_n/VALUE_n pairs.
|
||||
fn git_config(env: &[(String, String)], key: &str) -> Option<String> {
|
||||
let idx = env
|
||||
.iter()
|
||||
.find(|(k, v)| k.starts_with("GIT_CONFIG_KEY_") && v == key)?
|
||||
.0
|
||||
.strip_prefix("GIT_CONFIG_KEY_")?
|
||||
.to_owned();
|
||||
env.iter()
|
||||
.find(|(k, _)| *k == format!("GIT_CONFIG_VALUE_{idx}"))
|
||||
.map(|(_, v)| v.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ordinary_name_passes_through_unchanged() {
|
||||
assert_eq!(sanitize_git_user_name("Duncan"), Some("Duncan".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_angle_brackets_are_stripped_so_no_second_email_is_rendered() {
|
||||
// git drops the brackets itself and renders `Duncan evil@x.com
|
||||
// <hex@relay>` — no forgery, but a confusing author line.
|
||||
assert_eq!(
|
||||
sanitize_git_user_name("Duncan <evil@x.com>"),
|
||||
Some("Duncan evil@x.com".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_whitespace_control_characters_become_a_single_separator() {
|
||||
// Newline, tab and carriage return are whitespace: they collapse to one
|
||||
// space like any other run, so a multi-line name stays readable.
|
||||
assert_eq!(
|
||||
sanitize_git_user_name("Dun\ncan\tThe\r\nIdaho"),
|
||||
Some("Dun can The Idaho".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_whitespace_control_characters_are_dropped_outright() {
|
||||
// NUL is the important one: an interior NUL makes `Command::env` fail
|
||||
// the entire spawn upstream, so it must never survive to git config.
|
||||
let got = sanitize_git_user_name("Idaho\0Blade\u{7}").expect("non-empty");
|
||||
assert_eq!(got, "IdahoBlade");
|
||||
assert!(!got.chars().any(char::is_control));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_internal_whitespace_runs_collapse_to_one_space() {
|
||||
assert_eq!(
|
||||
sanitize_git_user_name(" Duncan Idaho "),
|
||||
Some("Duncan Idaho".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_whitespace_only_name_falls_back_to_npub() {
|
||||
assert_eq!(sanitize_git_user_name(" \t\n "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_name_falls_back_to_npub() {
|
||||
assert_eq!(sanitize_git_user_name(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crud_only_name_falls_back_rather_than_aborting_every_commit() {
|
||||
// git rejects a name built only of crud with `fatal: name consists
|
||||
// only of disallowed characters`, which would break EVERY commit the
|
||||
// agent makes. Verified against git 2.54.0.
|
||||
for raw in ["<>", ";;", "\"\"", "''", ",", ":", "\\", ",;:"] {
|
||||
assert_eq!(
|
||||
sanitize_git_user_name(raw),
|
||||
None,
|
||||
"crud-only name {raw:?} must fall back to the npub"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crud_mixed_with_real_characters_is_kept() {
|
||||
// Legitimate names contain crud; only an all-crud result is fatal.
|
||||
assert_eq!(sanitize_git_user_name("O'Brien"), Some("O'Brien".into()));
|
||||
assert_eq!(
|
||||
sanitize_git_user_name("Smith, Jr."),
|
||||
Some("Smith, Jr.".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_over_length_name_is_truncated_to_the_cap() {
|
||||
let long = "a".repeat(200);
|
||||
let got = sanitize_git_user_name(&long).expect("non-empty");
|
||||
assert_eq!(got.chars().count(), MAX_GIT_USER_NAME_CHARS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncation_never_splits_a_multibyte_character() {
|
||||
let long = "🐝".repeat(200);
|
||||
let got = sanitize_git_user_name(&long).expect("non-empty");
|
||||
assert_eq!(got.chars().count(), MAX_GIT_USER_NAME_CHARS);
|
||||
assert!(got.chars().all(|c| c == '🐝'), "no replacement chars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncation_does_not_leave_a_trailing_space() {
|
||||
// Cutting mid-word would otherwise strand the separator at the end.
|
||||
let raw = format!("{} tail", "a".repeat(MAX_GIT_USER_NAME_CHARS - 1));
|
||||
let got = sanitize_git_user_name(&raw).expect("non-empty");
|
||||
assert!(!got.ends_with(' '), "got {got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_ascii_names_survive() {
|
||||
assert_eq!(
|
||||
sanitize_git_user_name("Élodie 🐝"),
|
||||
Some("Élodie 🐝".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_only_name_falls_back_to_npub() {
|
||||
// U+200B is neither control, nor whitespace, nor crud, so before Cf
|
||||
// filtering this passed the non-crud gate and handed git a visually
|
||||
// blank author instead of falling back.
|
||||
assert_eq!(sanitize_git_user_name("\u{200B}\u{200B}"), None);
|
||||
// Same class, different marks: joiner, word joiner, BOM, bidi override.
|
||||
for raw in ["\u{200D}", "\u{2060}", "\u{FEFF}", "\u{202E}", "\u{00AD}"] {
|
||||
assert_eq!(
|
||||
sanitize_git_user_name(raw),
|
||||
None,
|
||||
"format-only name {raw:?} must fall back to the npub"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bidi_override_is_stripped_and_the_name_is_kept() {
|
||||
// A trailing RLO would reorder everything after it in `git log`, so the
|
||||
// mark goes and the readable name stays.
|
||||
assert_eq!(
|
||||
sanitize_git_user_name("Duncan\u{202E}"),
|
||||
Some("Duncan".into())
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_git_user_name("Dun\u{202E}can Idaho"),
|
||||
Some("Duncan Idaho".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_width_space_inside_a_word_is_removed_without_splitting_it() {
|
||||
// U+200B is not whitespace, so it must not become a separator: the word
|
||||
// rejoins rather than turning into "Dun can".
|
||||
assert_eq!(
|
||||
sanitize_git_user_name("Dun\u{200B}can"),
|
||||
Some("Duncan".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_characters_do_not_consume_the_length_budget() {
|
||||
// Filtering happens before truncation, so invisible padding cannot
|
||||
// shorten the visible name.
|
||||
let raw = format!("{}{}", "\u{200B}".repeat(200), "a".repeat(90));
|
||||
let got = sanitize_git_user_name(&raw).expect("non-empty");
|
||||
assert_eq!(got.chars().count(), MAX_GIT_USER_NAME_CHARS);
|
||||
assert!(got.chars().all(|c| c == 'a'), "got {got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unicode_format_covers_every_cf_range_and_nothing_adjacent() {
|
||||
// Both endpoints of each of the 21 `Cf` ranges in UCD 17.0.0. Endpoints
|
||||
// are what a transcription error moves, so they are what gets asserted.
|
||||
for c in [
|
||||
'\u{00AD}',
|
||||
'\u{0600}',
|
||||
'\u{0605}',
|
||||
'\u{061C}',
|
||||
'\u{06DD}',
|
||||
'\u{070F}',
|
||||
'\u{0890}',
|
||||
'\u{0891}',
|
||||
'\u{08E2}',
|
||||
'\u{180E}',
|
||||
'\u{200B}',
|
||||
'\u{200F}',
|
||||
'\u{202A}',
|
||||
'\u{202E}',
|
||||
'\u{2060}',
|
||||
'\u{2064}',
|
||||
'\u{2066}',
|
||||
'\u{206F}',
|
||||
'\u{FEFF}',
|
||||
'\u{FFF9}',
|
||||
'\u{FFFB}',
|
||||
'\u{110BD}',
|
||||
'\u{110CD}',
|
||||
'\u{13430}',
|
||||
'\u{1343F}',
|
||||
'\u{1BCA0}',
|
||||
'\u{1BCA3}',
|
||||
'\u{1D173}',
|
||||
'\u{1D17A}',
|
||||
'\u{E0001}',
|
||||
'\u{E0020}',
|
||||
'\u{E007F}',
|
||||
] {
|
||||
assert!(is_unicode_format(c), "U+{:04X} is Cf", c as u32);
|
||||
}
|
||||
// Codepoints immediately outside those ranges, plus ordinary characters.
|
||||
// U+2065 is the notable one: it sits *inside* the 2060..206F block but
|
||||
// is unassigned, not `Cf`.
|
||||
for c in [
|
||||
'\u{00AC}',
|
||||
'\u{00AE}',
|
||||
'\u{05FF}',
|
||||
'\u{0606}',
|
||||
'\u{061B}',
|
||||
'\u{061D}',
|
||||
'\u{200A}',
|
||||
'\u{2010}',
|
||||
'\u{2029}',
|
||||
'\u{202F}',
|
||||
'\u{2065}',
|
||||
'\u{205F}',
|
||||
'\u{2070}',
|
||||
'\u{FEFE}',
|
||||
'\u{FFF8}',
|
||||
'\u{FFFC}',
|
||||
'\u{110BC}',
|
||||
'\u{1342F}',
|
||||
'\u{E0000}',
|
||||
'\u{E0080}',
|
||||
'a',
|
||||
' ',
|
||||
'🐝',
|
||||
'É',
|
||||
] {
|
||||
assert!(!is_unicode_format(c), "U+{:04X} is not Cf", c as u32);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_git_env_uses_display_name_and_leaves_email_on_the_pubkey() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::set_var("BUZZ_ACP_DISPLAY_NAME", "Duncan");
|
||||
std::env::remove_var("BUZZ_RELAY_URL");
|
||||
std::env::remove_var("GIT_CONFIG_COUNT");
|
||||
let env = build_git_env(&key_info());
|
||||
std::env::remove_var("BUZZ_ACP_DISPLAY_NAME");
|
||||
|
||||
assert_eq!(git_config(&env, "user.name").as_deref(), Some("Duncan"));
|
||||
// The pubkey — the thing NIP-98 auth, NIP-GS signing, and contributor
|
||||
// matching key on — must stay in the email untouched.
|
||||
assert_eq!(
|
||||
git_config(&env, "user.email").as_deref(),
|
||||
Some(format!("{PUBKEY_HEX}@buzz").as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
git_config(&env, "user.signingkey").as_deref(),
|
||||
Some(PUBKEY_HEX)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_git_env_falls_back_to_npub_when_display_name_unset() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::remove_var("BUZZ_ACP_DISPLAY_NAME");
|
||||
std::env::remove_var("BUZZ_RELAY_URL");
|
||||
std::env::remove_var("GIT_CONFIG_COUNT");
|
||||
let env = build_git_env(&key_info());
|
||||
|
||||
// Today's behavior, and what every agent gets until a writer for
|
||||
// BUZZ_ACP_DISPLAY_NAME lands on the Desktop side.
|
||||
assert_eq!(git_config(&env, "user.name").as_deref(), Some(NPUB));
|
||||
assert_eq!(
|
||||
git_config(&env, "user.email").as_deref(),
|
||||
Some(format!("{PUBKEY_HEX}@buzz").as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_git_env_falls_back_to_npub_when_display_name_is_unusable() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::remove_var("BUZZ_RELAY_URL");
|
||||
std::env::remove_var("GIT_CONFIG_COUNT");
|
||||
|
||||
// Crud-only and format-only names both reach git as the npub — one
|
||||
// would abort every commit, the other would render as blank.
|
||||
for raw in ["<>", "\u{200B}"] {
|
||||
std::env::set_var("BUZZ_ACP_DISPLAY_NAME", raw);
|
||||
let env = build_git_env(&key_info());
|
||||
assert_eq!(
|
||||
git_config(&env, "user.name").as_deref(),
|
||||
Some(NPUB),
|
||||
"unusable display name {raw:?} must reach git as the npub"
|
||||
);
|
||||
}
|
||||
std::env::remove_var("BUZZ_ACP_DISPLAY_NAME");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_crud_set_matches_observed_git_behavior() {
|
||||
// Empirically derived from git 2.54.0: these bytes, alone, abort a commit.
|
||||
for c in [' ', '"', '\'', ',', ':', ';', '<', '>', '\\', '\t', '\n'] {
|
||||
assert!(is_git_crud(c), "{c:?} should be crud");
|
||||
}
|
||||
for c in ['.', '-', '_', '@', '(', 'a', '🐝'] {
|
||||
assert!(!is_git_crud(c), "{c:?} should not be crud");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
use crate::shell::SharedState;
|
||||
use rmcp::ErrorData;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use similar::{DiffTag, TextDiff};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
const MAX_INPUT_BYTES: usize = 1024 * 1024;
|
||||
const HINT_SCAN_LINE_LIMIT: usize = 200;
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct StrReplaceParams {
|
||||
pub path: String,
|
||||
pub old_str: String,
|
||||
pub new_str: String,
|
||||
/// When true, replace ALL occurrences of old_str instead of requiring
|
||||
/// exactly one match.
|
||||
#[serde(default)]
|
||||
pub replace_all: bool,
|
||||
#[serde(default)]
|
||||
pub workdir: Option<String>,
|
||||
}
|
||||
|
||||
pub fn run(state: &SharedState, p: StrReplaceParams) -> Result<String, ErrorData> {
|
||||
if p.old_str.is_empty() {
|
||||
return Err(ErrorData::invalid_params(
|
||||
"old_str must not be empty".to_string(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
if p.old_str.len() > MAX_INPUT_BYTES || p.new_str.len() > MAX_INPUT_BYTES {
|
||||
return Err(ErrorData::invalid_params(
|
||||
format!("old_str/new_str exceeds {} byte limit", MAX_INPUT_BYTES),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
let (target, content) = crate::paths::read_text_file(state, &p.path, p.workdir.as_deref())?;
|
||||
|
||||
let count = if p.replace_all {
|
||||
content.matches(&p.old_str).count()
|
||||
} else {
|
||||
count_occurrences_capped(&content, &p.old_str)
|
||||
};
|
||||
|
||||
if count == 0 {
|
||||
let hint = nearest_line_hint(&content, &p.old_str)
|
||||
.map(|h| format!("\n{h}"))
|
||||
.unwrap_or_default();
|
||||
return Err(ErrorData::invalid_params(
|
||||
format!(
|
||||
"old_str not found in {}.\nold_str (truncated): {:?}{hint}",
|
||||
target.display(),
|
||||
truncate(&p.old_str, 80)
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
if !p.replace_all && count > 1 {
|
||||
return Err(ErrorData::invalid_params(
|
||||
format!(
|
||||
"old_str matched multiple locations in {}; provide more surrounding context to make the match unique.",
|
||||
target.display()
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
// Preflight: reject before allocating if the result would exceed the limit.
|
||||
let size_delta = (p.new_str.len() as i64) - (p.old_str.len() as i64);
|
||||
let projected = (content.len() as i64).saturating_add(size_delta.saturating_mul(count as i64));
|
||||
if projected < 0 || projected as u64 > crate::paths::MAX_FILE_BYTES {
|
||||
return Err(ErrorData::invalid_params(
|
||||
format!(
|
||||
"result would exceed {} byte limit ({} bytes projected)",
|
||||
crate::paths::MAX_FILE_BYTES,
|
||||
projected
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
let new_content = if p.replace_all {
|
||||
content.replace(&p.old_str, &p.new_str)
|
||||
} else {
|
||||
content.replacen(&p.old_str, &p.new_str, 1)
|
||||
};
|
||||
|
||||
if let Err(e) = atomic_write(&target, &new_content) {
|
||||
return Err(ErrorData::internal_error(
|
||||
format!("failed to write {}: {e}", target.display()),
|
||||
None,
|
||||
));
|
||||
}
|
||||
let diff = unified_diff(&content, &new_content, &target);
|
||||
let label = if count == 1 {
|
||||
"1 occurrence".to_string()
|
||||
} else {
|
||||
format!("{count} occurrence(s)")
|
||||
};
|
||||
Ok(format!(
|
||||
"Replaced {label} in {}.\n\n{diff}",
|
||||
target.display()
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn count_occurrences_capped(text: &str, pattern: &str) -> usize {
|
||||
if pattern.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let mut count = 0;
|
||||
let mut start = 0;
|
||||
while let Some(pos) = text[start..].find(pattern) {
|
||||
count += 1;
|
||||
if count >= 2 {
|
||||
return count;
|
||||
}
|
||||
start += pos + pattern.len();
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
fn atomic_write(target: &Path, content: &str) -> std::io::Result<()> {
|
||||
let parent = target.parent().unwrap_or_else(|| Path::new("."));
|
||||
// Preserve original permissions so the atomic rename doesn't drop the file's mode.
|
||||
let original_perms = std::fs::metadata(target).ok().map(|m| m.permissions());
|
||||
|
||||
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
|
||||
tmp.write_all(content.as_bytes())?;
|
||||
tmp.flush()?;
|
||||
tmp.persist(target).map_err(|e| e.error)?;
|
||||
|
||||
if let Some(perms) = original_perms {
|
||||
let _ = std::fs::set_permissions(target, perms);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const MAX_DIFF_BYTES: usize = 64 * 1024;
|
||||
|
||||
fn unified_diff(old: &str, new: &str, path: &Path) -> String {
|
||||
let diff = TextDiff::from_lines(old, new);
|
||||
let display = path.display();
|
||||
let mut out = format!("--- a/{display}\n+++ b/{display}\n");
|
||||
for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
|
||||
let h = hunk.to_string();
|
||||
if out.len() + h.len() > MAX_DIFF_BYTES {
|
||||
out.push_str("\n[diff truncated]\n");
|
||||
break;
|
||||
}
|
||||
out.push_str(&h);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max_chars: usize) -> String {
|
||||
if s.chars().count() <= max_chars {
|
||||
s.to_string()
|
||||
} else {
|
||||
let head: String = s.chars().take(max_chars).collect();
|
||||
format!("{head}…")
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_str(s: &str, max: usize) -> &str {
|
||||
if s.len() <= max {
|
||||
return s;
|
||||
}
|
||||
let mut cut = max;
|
||||
while cut > 0 && !s.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
&s[..cut]
|
||||
}
|
||||
|
||||
fn similarity(a: &str, b: &str) -> f64 {
|
||||
if a == b {
|
||||
return 1.0;
|
||||
}
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
const MAX: usize = 512;
|
||||
let a = truncate_str(a, MAX);
|
||||
let b = truncate_str(b, MAX);
|
||||
let matched: usize = TextDiff::from_chars(a, b)
|
||||
.ops()
|
||||
.iter()
|
||||
.filter(|op| matches!(op.tag(), DiffTag::Equal))
|
||||
.map(|op| op.new_range().len())
|
||||
.sum();
|
||||
matched as f64 / a.len().max(b.len()) as f64
|
||||
}
|
||||
|
||||
fn nearest_line_hint(content: &str, pattern: &str) -> Option<String> {
|
||||
let first = pattern.lines().next()?.trim();
|
||||
if first.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let best = content
|
||||
.lines()
|
||||
.take(HINT_SCAN_LINE_LIMIT)
|
||||
.enumerate()
|
||||
.map(|(i, line)| (i, similarity(line.trim(), first), line))
|
||||
.filter(|(_, s, _)| *s > 0.6)
|
||||
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))?;
|
||||
Some(format!(
|
||||
"Hint: nearest match around line {} (similarity {:.2}):\n found: {:?}\n expected: {:?}",
|
||||
best.0 + 1,
|
||||
best.1,
|
||||
best.2.trim(),
|
||||
first
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn count_occurrences_capped_returns_0_1_2() {
|
||||
assert_eq!(count_occurrences_capped("hello world", "x"), 0);
|
||||
assert_eq!(count_occurrences_capped("hello world", "hello"), 1);
|
||||
assert_eq!(count_occurrences_capped("a a a a a", "a"), 2); // capped at 2
|
||||
assert_eq!(count_occurrences_capped("abc", ""), 0);
|
||||
}
|
||||
|
||||
fn make_state(cwd: &std::path::Path) -> SharedState {
|
||||
let shim = crate::shim::Shim::install().expect("shim install");
|
||||
SharedState::new(cwd.to_path_buf(), shim).expect("state new")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_basic_replace_emits_diff() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let f = dir.path().join("a.txt");
|
||||
fs::write(&f, "alpha\nbeta\ngamma\n").expect("write");
|
||||
let state = make_state(dir.path());
|
||||
let p = StrReplaceParams {
|
||||
path: "a.txt".into(),
|
||||
old_str: "beta".into(),
|
||||
new_str: "BETA".into(),
|
||||
replace_all: false,
|
||||
workdir: Some(dir.path().display().to_string()),
|
||||
};
|
||||
let out = run(&state, p).expect("ok");
|
||||
assert!(out.contains("Replaced 1 occurrence"), "out: {out}");
|
||||
assert!(out.contains("-beta"), "out: {out}");
|
||||
assert!(out.contains("+BETA"), "out: {out}");
|
||||
let contents = fs::read_to_string(&f).expect("read");
|
||||
assert_eq!(contents, "alpha\nBETA\ngamma\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_allows_path_outside_workspace() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
// A real file in a SECOND tempdir, genuinely outside the workspace
|
||||
// root, that does NOT contain our old_str — we expect a "not found"
|
||||
// error (proving the path resolved), not a path-escape error. Avoids
|
||||
// the Unix-only /etc/hosts assumption (C:\etc\hosts does not exist).
|
||||
let outside = tempdir().expect("tempdir");
|
||||
let target = outside.path().join("outside.txt");
|
||||
fs::write(&target, b"some content").expect("write");
|
||||
let state = make_state(dir.path());
|
||||
let p = StrReplaceParams {
|
||||
path: target.display().to_string(),
|
||||
old_str: "UNIQUE_STRING_NOT_IN_FILE_abc123".into(),
|
||||
new_str: "y".into(),
|
||||
replace_all: false,
|
||||
workdir: Some(dir.path().display().to_string()),
|
||||
};
|
||||
let err = run(&state, p).unwrap_err();
|
||||
let msg = format!("{err:?}");
|
||||
assert!(
|
||||
msg.contains("not found"),
|
||||
"expected 'not found' error (proving path resolved), got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_rejects_file_too_large() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let f = dir.path().join("big.bin");
|
||||
let big = vec![b'a'; (crate::paths::MAX_FILE_BYTES as usize) + 1024];
|
||||
fs::write(&f, &big).expect("write");
|
||||
let state = make_state(dir.path());
|
||||
let p = StrReplaceParams {
|
||||
path: "big.bin".into(),
|
||||
old_str: "a".into(),
|
||||
new_str: "b".into(),
|
||||
replace_all: false,
|
||||
workdir: Some(dir.path().display().to_string()),
|
||||
};
|
||||
let err = run(&state, p).unwrap_err();
|
||||
let msg = format!("{err:?}");
|
||||
assert!(msg.contains("too large"), "msg: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_replace_all_replaces_all_occurrences() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let f = dir.path().join("multi.txt");
|
||||
fs::write(&f, "foo bar foo baz foo\n").expect("write");
|
||||
let state = make_state(dir.path());
|
||||
let p = StrReplaceParams {
|
||||
path: "multi.txt".into(),
|
||||
old_str: "foo".into(),
|
||||
new_str: "qux".into(),
|
||||
replace_all: true,
|
||||
workdir: Some(dir.path().display().to_string()),
|
||||
};
|
||||
let out = run(&state, p).expect("ok");
|
||||
assert!(out.contains("Replaced 3 occurrence(s)"), "out: {out}");
|
||||
let contents = fs::read_to_string(&f).expect("read");
|
||||
assert_eq!(contents, "qux bar qux baz qux\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_replace_all_errors_on_zero_matches() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let f = dir.path().join("nomatch.txt");
|
||||
fs::write(&f, "hello world\n").expect("write");
|
||||
let state = make_state(dir.path());
|
||||
let p = StrReplaceParams {
|
||||
path: "nomatch.txt".into(),
|
||||
old_str: "xyz".into(),
|
||||
new_str: "abc".into(),
|
||||
replace_all: true,
|
||||
workdir: Some(dir.path().display().to_string()),
|
||||
};
|
||||
let err = run(&state, p).unwrap_err();
|
||||
let msg = format!("{err:?}");
|
||||
assert!(msg.contains("not found"), "msg: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_without_replace_all_preserves_single_match_behavior() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let f = dir.path().join("multi2.txt");
|
||||
fs::write(&f, "foo bar foo\n").expect("write");
|
||||
let state = make_state(dir.path());
|
||||
let p = StrReplaceParams {
|
||||
path: "multi2.txt".into(),
|
||||
old_str: "foo".into(),
|
||||
new_str: "qux".into(),
|
||||
replace_all: false,
|
||||
workdir: Some(dir.path().display().to_string()),
|
||||
};
|
||||
let err = run(&state, p).unwrap_err();
|
||||
let msg = format!("{err:?}");
|
||||
assert!(msg.contains("matched multiple locations"), "msg: {msg}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
//! In-memory session task list with `_Stop` and `_PostCompact` hooks.
|
||||
//!
|
||||
//! - `todo`: read or full-list-replace the task list. Empty args reads.
|
||||
//! - `_Stop`: hook called by the agent before honoring end_turn. Returns
|
||||
//! objection text if any items are open, empty otherwise.
|
||||
//! - `_PostCompact`: hook called after context compaction/handoff.
|
||||
//! Returns the full list state so the agent can re-inject it.
|
||||
//!
|
||||
//! State is per-process (Vec<Item> behind a Mutex). Items are
|
||||
//! `{text, done}` — no ids; the LLM provides a full replacement list.
|
||||
//! On replacement, open items that disappear without being marked done
|
||||
//! trigger a soft warning appended to the tool response.
|
||||
|
||||
use rmcp::model::{CallToolResult, Content};
|
||||
use rmcp::ErrorData;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const MAX_ITEMS: usize = 50;
|
||||
const MAX_TEXT_CHARS: usize = 200;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Item {
|
||||
#[schemars(length(min = 1, max = 200))]
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub done: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TodoParams {
|
||||
/// Full replacement list (max 50 items). Omit to read.
|
||||
// Both omitted and explicit null mean "read current state".
|
||||
#[serde(default)]
|
||||
#[schemars(length(max = 50))]
|
||||
pub todos: Option<Vec<Item>>,
|
||||
}
|
||||
|
||||
/// Empty params struct for the hook tools. Hooks take no arguments but
|
||||
/// rmcp requires Parameters<T> for the macro.
|
||||
#[derive(Debug, Deserialize, JsonSchema, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HookParams {}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TodoState {
|
||||
items: Mutex<Vec<Item>>,
|
||||
}
|
||||
|
||||
impl TodoState {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn with_items<R>(&self, f: impl FnOnce(&mut Vec<Item>) -> R) -> R {
|
||||
let mut g = match self.items.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
f(&mut g)
|
||||
}
|
||||
|
||||
/// Replace-or-read. Returns the rendered list, with a warning
|
||||
/// appended if open items were silently removed. Mutation, warning
|
||||
/// computation, and rendering all occur under a single lock hold so
|
||||
/// that the rendered output reflects exactly the list that was just
|
||||
/// written (no interleaving with concurrent calls).
|
||||
pub fn handle_todo(&self, params: TodoParams) -> Result<String, String> {
|
||||
if let Some(mut new_items) = params.todos {
|
||||
validate(&new_items)?;
|
||||
// Normalize: trim text on storage so duplicate detection
|
||||
// and silent-removal diff operate on the same canonical
|
||||
// form. Validation already rejected empty-after-trim text,
|
||||
// duplicates after trim, and invalid characters.
|
||||
for it in &mut new_items {
|
||||
it.text = it.text.trim().to_owned();
|
||||
}
|
||||
return Ok(self.with_items(|items| {
|
||||
let warning = silent_removal_warning(items, &new_items);
|
||||
*items = new_items;
|
||||
let mut out = render_items(items);
|
||||
if !warning.is_empty() {
|
||||
out.push('\n');
|
||||
out.push_str(&warning);
|
||||
}
|
||||
out
|
||||
}));
|
||||
}
|
||||
Ok(self.render())
|
||||
}
|
||||
|
||||
pub fn render(&self) -> String {
|
||||
self.with_items(|items| render_items(items))
|
||||
}
|
||||
|
||||
/// Objection text if open items exist, empty string otherwise.
|
||||
pub fn stop_objection(&self) -> String {
|
||||
self.with_items(|items| {
|
||||
if items.iter().any(|i| !i.done) {
|
||||
format!(
|
||||
"You have open todo items. Keep working.\n\n{}",
|
||||
render_items(items)
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-injection block for after a handoff. Empty if no items.
|
||||
pub fn post_compact(&self) -> String {
|
||||
self.with_items(|items| {
|
||||
if items.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("# Todo List\n{}", render_items(items))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject characters that aren't safe for single-line display. ASCII
|
||||
/// space is the only whitespace allowed; everything else (control
|
||||
/// chars, exotic whitespace, bidi/format chars, zero-width joiners,
|
||||
/// BOM) could be used to spoof the rendered todo output.
|
||||
fn invalid_text_char(c: char) -> bool {
|
||||
if c.is_control() {
|
||||
return true;
|
||||
}
|
||||
if c.is_whitespace() && c != ' ' {
|
||||
// Catches \t, \n, \r, NBSP (\u{00A0}), line/paragraph
|
||||
// separators (\u{2028}, \u{2029}), and other exotic spaces.
|
||||
return true;
|
||||
}
|
||||
matches!(c,
|
||||
'\u{200B}'..='\u{200F}' // zero-width spaces, LRM/RLM
|
||||
| '\u{202A}'..='\u{202E}' // bidi embedding/override
|
||||
| '\u{2060}'..='\u{206F}' // word joiner, invisible operators
|
||||
| '\u{FEFF}' // BOM / zero-width no-break space
|
||||
)
|
||||
}
|
||||
|
||||
fn validate(items: &[Item]) -> Result<(), String> {
|
||||
if items.len() > MAX_ITEMS {
|
||||
return Err(format!("too many items (max {MAX_ITEMS})"));
|
||||
}
|
||||
for (i, it) in items.iter().enumerate() {
|
||||
if it.text.trim().is_empty() {
|
||||
return Err(format!("item {}: text is empty", i + 1));
|
||||
}
|
||||
if it.text.trim().chars().count() > MAX_TEXT_CHARS {
|
||||
return Err(format!(
|
||||
"item {}: text exceeds {MAX_TEXT_CHARS} characters",
|
||||
i + 1
|
||||
));
|
||||
}
|
||||
// Reject control characters and Unicode trickery that could
|
||||
// spoof the rendered output: line/paragraph separators, bidi
|
||||
// overrides, zero-width joiners, BOM, etc. See
|
||||
// `invalid_text_char` for the full list.
|
||||
if it.text.chars().any(invalid_text_char) {
|
||||
return Err(format!("item {}: text contains invalid characters", i + 1));
|
||||
}
|
||||
}
|
||||
// Reject duplicates (after trim). Without ids, duplicates are
|
||||
// ambiguous: silent-removal detection can't tell which copy was
|
||||
// dropped, and they have no semantic meaning anyway.
|
||||
for (i, a) in items.iter().enumerate() {
|
||||
let a_trim = a.text.trim();
|
||||
for (j, b) in items.iter().enumerate().skip(i + 1) {
|
||||
if a_trim == b.text.trim() {
|
||||
return Err(format!(
|
||||
"item {}: duplicate text (matches item {})",
|
||||
j + 1,
|
||||
i + 1
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Diff old vs new. Any open (`!done`) item in `old` whose `text` is
|
||||
/// absent from `new` (any position, any done-state) is "silently
|
||||
/// removed". Returns the warning string, or empty if none.
|
||||
///
|
||||
/// `validate()` rejects duplicate text in `new`, so a `HashSet` lookup
|
||||
/// is unambiguous here.
|
||||
fn silent_removal_warning(old: &[Item], new: &[Item]) -> String {
|
||||
let new_texts: std::collections::HashSet<&str> = new.iter().map(|i| i.text.as_str()).collect();
|
||||
let removed: Vec<&str> = old
|
||||
.iter()
|
||||
.filter(|i| !i.done && !new_texts.contains(i.text.as_str()))
|
||||
.map(|i| i.text.as_str())
|
||||
.collect();
|
||||
if removed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut out = format!(
|
||||
"⚠️ {} open item{} {} removed from the list:\n",
|
||||
removed.len(),
|
||||
if removed.len() == 1 { "" } else { "s" },
|
||||
if removed.len() == 1 { "was" } else { "were" },
|
||||
);
|
||||
for t in &removed {
|
||||
out.push_str(" - \"");
|
||||
out.push_str(t);
|
||||
out.push_str("\"\n");
|
||||
}
|
||||
out.push_str(
|
||||
"If accidental, re-add them. If they are complete, prefer marking items done before removing.",
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
fn render_items(items: &[Item]) -> String {
|
||||
if items.is_empty() {
|
||||
return "(todo list is empty)".into();
|
||||
}
|
||||
let next = items.iter().position(|i| !i.done);
|
||||
let mut out = String::with_capacity(64 * items.len());
|
||||
for (i, it) in items.iter().enumerate() {
|
||||
let box_ = if it.done { "[x]" } else { "[ ]" };
|
||||
out.push_str(box_);
|
||||
out.push(' ');
|
||||
out.push_str(&(i + 1).to_string());
|
||||
out.push_str(". ");
|
||||
out.push_str(&it.text);
|
||||
if Some(i) == next {
|
||||
out.push_str(" ← next");
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Wrap a string result as an MCP CallToolResult with text content.
|
||||
pub fn text_result(s: String) -> Result<CallToolResult, ErrorData> {
|
||||
Ok(CallToolResult::success(vec![Content::text(s)]))
|
||||
}
|
||||
|
||||
/// Wrap an error string as an MCP CallToolResult with isError=true.
|
||||
pub fn error_result(s: String) -> Result<CallToolResult, ErrorData> {
|
||||
Ok(CallToolResult::error(vec![Content::text(s)]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn mk(items: &[(&str, bool)]) -> Vec<Item> {
|
||||
items
|
||||
.iter()
|
||||
.map(|(text, done)| Item {
|
||||
text: (*text).to_owned(),
|
||||
done: *done,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write(s: &TodoState, items: &[(&str, bool)]) -> String {
|
||||
s.handle_todo(TodoParams {
|
||||
todos: Some(mk(items)),
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_read_returns_placeholder() {
|
||||
let s = TodoState::new();
|
||||
let out = s.handle_todo(TodoParams { todos: None }).unwrap();
|
||||
assert!(out.contains("empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_text() {
|
||||
let s = TodoState::new();
|
||||
let err = s
|
||||
.handle_todo(TodoParams {
|
||||
todos: Some(mk(&[(" ", false)])),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.contains("text is empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_many_items() {
|
||||
let s = TodoState::new();
|
||||
let many: Vec<Item> = (0..=MAX_ITEMS)
|
||||
.map(|i| Item {
|
||||
text: format!("item {i}"),
|
||||
done: false,
|
||||
})
|
||||
.collect();
|
||||
let err = s.handle_todo(TodoParams { todos: Some(many) }).unwrap_err();
|
||||
assert!(err.contains("too many items"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_text() {
|
||||
let s = TodoState::new();
|
||||
let err = s
|
||||
.handle_todo(TodoParams {
|
||||
todos: Some(mk(&[("same", false), ("same", true)])),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.contains("duplicate"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_text_after_trim() {
|
||||
let s = TodoState::new();
|
||||
let err = s
|
||||
.handle_todo(TodoParams {
|
||||
todos: Some(mk(&[("foo", false), (" foo ", false)])),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.contains("duplicate"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_control_characters() {
|
||||
let s = TodoState::new();
|
||||
let err_n = s
|
||||
.handle_todo(TodoParams {
|
||||
todos: Some(mk(&[("line1\nline2", false)])),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err_n.contains("invalid characters"), "got: {err_n}");
|
||||
let err_t = s
|
||||
.handle_todo(TodoParams {
|
||||
todos: Some(mk(&[("col1\tcol2", false)])),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err_t.contains("invalid characters"), "got: {err_t}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unicode_line_separator() {
|
||||
let s = TodoState::new();
|
||||
let err = s
|
||||
.handle_todo(TodoParams {
|
||||
todos: Some(mk(&[("line1\u{2028}line2", false)])),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.contains("invalid characters"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bidi_override() {
|
||||
let s = TodoState::new();
|
||||
let err = s
|
||||
.handle_todo(TodoParams {
|
||||
todos: Some(mk(&[("text\u{202E}reversed", false)])),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.contains("invalid characters"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_width() {
|
||||
let s = TodoState::new();
|
||||
let err = s
|
||||
.handle_todo(TodoParams {
|
||||
todos: Some(mk(&[("zero\u{200B}width", false)])),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.contains("invalid characters"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_trailing_spaces_trimmed_on_store() {
|
||||
let s = TodoState::new();
|
||||
// Send with surrounding spaces.
|
||||
let out = write(&s, &[(" task ", false)]);
|
||||
// Render should have the trimmed form; no leading/trailing
|
||||
// spaces in the displayed text.
|
||||
assert!(out.contains("[ ] 1. task"));
|
||||
assert!(!out.contains(" task "));
|
||||
// A subsequent write of the trimmed form should not falsely
|
||||
// warn that " task " was silently removed: stored text is
|
||||
// already "task", so the diff sees no removal.
|
||||
let out2 = write(&s, &[("task", false)]);
|
||||
assert!(!out2.contains("⚠️"), "unexpected warning: {out2}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_params_rejects_unknown_fields() {
|
||||
let res: Result<HookParams, _> = serde_json::from_str(r#"{"x":1}"#);
|
||||
assert!(res.is_err(), "expected error, got: {res:?}");
|
||||
// Sanity: empty object parses.
|
||||
let ok: Result<HookParams, _> = serde_json::from_str("{}");
|
||||
assert!(ok.is_ok(), "expected ok, got: {ok:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_null_is_read() {
|
||||
let s = TodoState::new();
|
||||
write(&s, &[("alpha", false)]);
|
||||
// {"todos": null} should behave the same as omitting the field
|
||||
// — return the current list, don't clear it.
|
||||
let params: TodoParams = serde_json::from_str(r#"{"todos":null}"#).unwrap();
|
||||
let out = s.handle_todo(params).unwrap();
|
||||
assert!(out.contains("[ ] 1. alpha"), "got: {out}");
|
||||
// Verify state was not mutated.
|
||||
let again = s.render();
|
||||
assert!(again.contains("[ ] 1. alpha"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_fields() {
|
||||
// Unknown field on Item: `complete` instead of `done`.
|
||||
let bad_item = r#"{"todos":[{"text":"x","complete":true}]}"#;
|
||||
let res: Result<TodoParams, _> = serde_json::from_str(bad_item);
|
||||
assert!(res.is_err(), "expected error, got: {res:?}");
|
||||
// Unknown field on TodoParams.
|
||||
let bad_root = r#"{"todos":[{"text":"x"}],"extra":1}"#;
|
||||
let res: Result<TodoParams, _> = serde_json::from_str(bad_root);
|
||||
assert!(res.is_err(), "expected error, got: {res:?}");
|
||||
// Sanity: the correct shape parses.
|
||||
let good = r#"{"todos":[{"text":"x","done":true}]}"#;
|
||||
let res: Result<TodoParams, _> = serde_json::from_str(good);
|
||||
assert!(res.is_ok(), "expected ok, got: {res:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_at_boundary_200_chars() {
|
||||
let s = TodoState::new();
|
||||
let exactly_200 = "a".repeat(200);
|
||||
let out = s
|
||||
.handle_todo(TodoParams {
|
||||
todos: Some(mk(&[(exactly_200.as_str(), false)])),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(out.contains(&exactly_200));
|
||||
let too_long = "b".repeat(201);
|
||||
let err = s
|
||||
.handle_todo(TodoParams {
|
||||
todos: Some(mk(&[(too_long.as_str(), false)])),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.contains("exceeds"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_and_render_atomic() {
|
||||
// The string returned from a write must reflect the list that
|
||||
// was just written, not stale state. With the atomic
|
||||
// implementation the response IS computed under the same lock
|
||||
// hold as the mutation, so this is guaranteed structurally; we
|
||||
// assert the contract.
|
||||
let s = TodoState::new();
|
||||
let out = write(&s, &[("alpha", false), ("beta", true)]);
|
||||
assert!(out.contains("[ ] 1. alpha"));
|
||||
assert!(out.contains("[x] 2. beta"));
|
||||
// And a subsequent render returns the same content.
|
||||
let again = s.render();
|
||||
assert!(again.contains("[ ] 1. alpha"));
|
||||
assert!(again.contains("[x] 2. beta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_uses_position_numbers() {
|
||||
let s = TodoState::new();
|
||||
let out = write(&s, &[("first", true), ("second", false), ("third", false)]);
|
||||
assert!(out.contains("[x] 1. first"));
|
||||
assert!(out.contains("[ ] 2. second"));
|
||||
assert!(out.contains("[ ] 3. third"));
|
||||
// ← next on first open item
|
||||
assert!(out.contains("[ ] 2. second ← next"));
|
||||
assert!(!out.contains("[ ] 3. third ← next"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_returns_objection_when_open_items_exist() {
|
||||
let s = TodoState::new();
|
||||
write(&s, &[("a", false), ("b", false)]);
|
||||
let obj = s.stop_objection();
|
||||
assert!(!obj.is_empty(), "expected non-empty objection");
|
||||
assert!(obj.contains("open todo items"));
|
||||
assert!(obj.contains("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_returns_empty_when_all_done() {
|
||||
let s = TodoState::new();
|
||||
write(&s, &[("a", true), ("b", true)]);
|
||||
assert_eq!(s.stop_objection(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_returns_empty_when_list_is_empty() {
|
||||
assert_eq!(TodoState::new().stop_objection(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_compact_renders_when_populated() {
|
||||
let s = TodoState::new();
|
||||
write(&s, &[("a", false)]);
|
||||
let block = s.post_compact();
|
||||
assert!(block.starts_with("# Todo List\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_compact_empty_when_no_items() {
|
||||
assert_eq!(TodoState::new().post_compact(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silent_removal_warns() {
|
||||
let s = TodoState::new();
|
||||
write(
|
||||
&s,
|
||||
&[
|
||||
("fix the flaky test", false),
|
||||
("write unit tests", false),
|
||||
("ship it", false),
|
||||
],
|
||||
);
|
||||
let out = write(&s, &[("ship it", false)]);
|
||||
assert!(out.contains("⚠️"), "expected warning, got: {out}");
|
||||
assert!(out.contains("2 open items"));
|
||||
assert!(out.contains("\"fix the flaky test\""));
|
||||
assert!(out.contains("\"write unit tests\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_of_done_item_no_warning() {
|
||||
let s = TodoState::new();
|
||||
write(&s, &[("done thing", true), ("open thing", false)]);
|
||||
let out = write(&s, &[("open thing", false)]);
|
||||
assert!(!out.contains("⚠️"), "unexpected warning: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_triggers_warning() {
|
||||
let s = TodoState::new();
|
||||
write(&s, &[("write tests", false)]);
|
||||
let out = write(&s, &[("write unit tests", false)]);
|
||||
assert!(out.contains("⚠️"), "expected warning, got: {out}");
|
||||
assert!(out.contains("\"write tests\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marking_done_then_removing_no_warning() {
|
||||
let s = TodoState::new();
|
||||
write(&s, &[("task", false)]);
|
||||
// Mark done.
|
||||
let out1 = write(&s, &[("task", true)]);
|
||||
assert!(!out1.contains("⚠️"));
|
||||
// Now remove (it's done in the prior list).
|
||||
let out2 = write(&s, &[]);
|
||||
assert!(!out2.contains("⚠️"), "unexpected warning: {out2}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
const MAX_OUTPUT_BYTES: usize = 50 * 1024;
|
||||
const MAX_OUTPUT_LINES: usize = 2000;
|
||||
const MAX_WALK_DEPTH: usize = 50;
|
||||
const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024;
|
||||
|
||||
struct Frame {
|
||||
depth: usize,
|
||||
out_idx: usize,
|
||||
total: usize,
|
||||
leaf: bool,
|
||||
}
|
||||
|
||||
pub fn run(args: Vec<String>) -> i32 {
|
||||
let (root, max_depth) = match parse(args) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::error!("tree: {e}");
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
if !root.is_dir() {
|
||||
tracing::error!("tree: not a directory: {}", root.display());
|
||||
return 2;
|
||||
}
|
||||
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut stack: Vec<Frame> = vec![Frame {
|
||||
depth: 0,
|
||||
out_idx: 0,
|
||||
total: 0,
|
||||
leaf: false,
|
||||
}];
|
||||
let line_budget = MAX_OUTPUT_LINES.saturating_sub(1);
|
||||
let mut truncated = false;
|
||||
|
||||
let mut builder = WalkBuilder::new(&root);
|
||||
builder.git_ignore(true);
|
||||
builder.git_exclude(true);
|
||||
builder.git_global(true);
|
||||
builder.require_git(false);
|
||||
builder.ignore(true);
|
||||
builder.hidden(true);
|
||||
builder.max_depth(Some(max_depth));
|
||||
builder.sort_by_file_name(|a, b| a.cmp(b));
|
||||
|
||||
for entry in builder.build().flatten() {
|
||||
if entry.depth() == 0 {
|
||||
continue;
|
||||
}
|
||||
let depth = entry.depth();
|
||||
let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
|
||||
let is_file = entry.file_type().is_some_and(|t| t.is_file());
|
||||
|
||||
while stack.last().is_some_and(|f| f.depth >= depth) {
|
||||
let Some(frame) = stack.pop() else { break };
|
||||
if let Some(parent) = stack.last_mut() {
|
||||
parent.total = parent.total.saturating_add(frame.total);
|
||||
}
|
||||
if frame.depth > 0 && !frame.leaf {
|
||||
let placeholder = &mut out[frame.out_idx];
|
||||
*placeholder = format!("{} [{}]", placeholder, frame.total);
|
||||
}
|
||||
}
|
||||
|
||||
if out.len() >= line_budget {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let prefix = " ".repeat(depth - 1);
|
||||
let name = entry.file_name().to_string_lossy();
|
||||
|
||||
if is_dir {
|
||||
let idx = out.len();
|
||||
out.push(format!("{prefix}{name}/"));
|
||||
stack.push(Frame {
|
||||
depth,
|
||||
out_idx: idx,
|
||||
total: 0,
|
||||
leaf: depth == max_depth,
|
||||
});
|
||||
} else if is_file {
|
||||
let lc = line_count(entry.path());
|
||||
if let Some(parent) = stack.last_mut() {
|
||||
parent.total = parent.total.saturating_add(lc);
|
||||
}
|
||||
out.push(format!("{prefix}{name} [{lc}]"));
|
||||
}
|
||||
}
|
||||
|
||||
let mut grand_total = 0usize;
|
||||
while let Some(frame) = stack.pop() {
|
||||
if let Some(parent) = stack.last_mut() {
|
||||
parent.total = parent.total.saturating_add(frame.total);
|
||||
}
|
||||
if frame.depth == 0 {
|
||||
grand_total = frame.total;
|
||||
} else if !frame.leaf {
|
||||
let placeholder = &mut out[frame.out_idx];
|
||||
*placeholder = format!("{} [{}]", placeholder, frame.total);
|
||||
}
|
||||
}
|
||||
|
||||
let root_name = root
|
||||
.file_name()
|
||||
.unwrap_or(root.as_os_str())
|
||||
.to_string_lossy();
|
||||
|
||||
let stdout = std::io::stdout();
|
||||
let mut w = stdout.lock();
|
||||
if writeln!(w, "{root_name}/ [{grand_total}]").is_err() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut bytes = 0usize;
|
||||
for line in &out {
|
||||
if bytes + line.len() + 1 > MAX_OUTPUT_BYTES {
|
||||
let _ = writeln!(w, "[truncated]");
|
||||
return 0;
|
||||
}
|
||||
if writeln!(w, "{line}").is_err() {
|
||||
return 0;
|
||||
}
|
||||
bytes += line.len() + 1;
|
||||
}
|
||||
if truncated {
|
||||
let _ = writeln!(w, "[truncated]");
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
fn parse(args: Vec<String>) -> Result<(PathBuf, usize), String> {
|
||||
let mut depth = MAX_WALK_DEPTH;
|
||||
let mut path = PathBuf::from(".");
|
||||
let mut path_set = false;
|
||||
let mut iter = args.into_iter();
|
||||
while let Some(a) = iter.next() {
|
||||
match a.as_str() {
|
||||
"--" => {
|
||||
if let Some(p) = iter.next() {
|
||||
if path_set {
|
||||
return Err("multiple paths not supported".to_string());
|
||||
}
|
||||
path = PathBuf::from(p);
|
||||
}
|
||||
break;
|
||||
}
|
||||
"-d" | "--depth" => {
|
||||
let n = iter.next().ok_or("missing value for --depth")?;
|
||||
depth = n.parse::<usize>().map_err(|_| format!("bad depth: {n}"))?;
|
||||
}
|
||||
s if s.starts_with('-') => return Err(format!("unknown flag: {s}")),
|
||||
_ => {
|
||||
if path_set {
|
||||
return Err("multiple paths not supported".to_string());
|
||||
}
|
||||
path = PathBuf::from(a);
|
||||
path_set = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((path, depth.min(MAX_WALK_DEPTH)))
|
||||
}
|
||||
|
||||
fn line_count(path: &Path) -> usize {
|
||||
std::fs::metadata(path)
|
||||
.ok()
|
||||
.filter(|m| m.is_file() && m.len() <= MAX_FILE_BYTES)
|
||||
.and_then(|_| std::fs::read(path).ok())
|
||||
.map(|b| {
|
||||
if b.is_empty() {
|
||||
0
|
||||
} else {
|
||||
b.iter().filter(|&&c| c == b'\n').count()
|
||||
+ if b.last() != Some(&b'\n') { 1 } else { 0 }
|
||||
}
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user