harness-write 0.3.0

Write/Edit/MultiEdit tool for AI agent harnesses — atomic write, read-before-edit ledger, OLD_STRING_NOT_UNIQUE with match locations, OLD_STRING_NOT_FOUND with fuzzy candidates, sequential multi-edit pipeline
Documentation
use serde::{Deserialize, Serialize};
use serde_json::Value;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WriteParams {
    pub path: String,
    pub content: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EditParams {
    pub path: String,
    pub old_string: String,
    pub new_string: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub replace_all: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dry_run: Option<bool>,
    /// When true, leading/trailing whitespace on each line is ignored
    /// during matching. The replacement uses the exact new_string text.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub ignore_whitespace: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EditSpec {
    pub old_string: String,
    pub new_string: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub replace_all: Option<bool>,
    /// When true, leading/trailing whitespace on each line is ignored
    /// during matching.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub ignore_whitespace: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MultiEditParams {
    pub path: String,
    pub edits: Vec<EditSpec>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dry_run: Option<bool>,
}

#[derive(Debug, Clone, thiserror::Error)]
pub enum WriteParseError {
    #[error("{0}")]
    Message(String),
}

pub fn safe_parse_write_params(input: &Value) -> Result<WriteParams, WriteParseError> {
    let parsed: WriteParams = serde_json::from_value(input.clone())
        .map_err(|e| WriteParseError::Message(e.to_string()))?;
    if parsed.path.is_empty() {
        return Err(WriteParseError::Message("path must not be empty".to_string()));
    }
    Ok(parsed)
}

pub fn safe_parse_edit_params(input: &Value) -> Result<EditParams, WriteParseError> {
    let parsed: EditParams = serde_json::from_value(input.clone())
        .map_err(|e| WriteParseError::Message(e.to_string()))?;
    if parsed.path.is_empty() {
        return Err(WriteParseError::Message("path must not be empty".to_string()));
    }
    if parsed.old_string.is_empty() {
        return Err(WriteParseError::Message(
            "old_string must not be empty".to_string(),
        ));
    }
    Ok(parsed)
}

pub fn safe_parse_multi_edit_params(input: &Value) -> Result<MultiEditParams, WriteParseError> {
    let parsed: MultiEditParams = serde_json::from_value(input.clone())
        .map_err(|e| WriteParseError::Message(e.to_string()))?;
    if parsed.path.is_empty() {
        return Err(WriteParseError::Message("path must not be empty".to_string()));
    }
    if parsed.edits.is_empty() {
        return Err(WriteParseError::Message(
            "edits must contain at least one edit".to_string(),
        ));
    }
    for (i, e) in parsed.edits.iter().enumerate() {
        if e.old_string.is_empty() {
            return Err(WriteParseError::Message(format!(
                "edits[{}].old_string must not be empty",
                i
            )));
        }
    }
    Ok(parsed)
}

pub const WRITE_TOOL_NAME: &str = "write";
pub const EDIT_TOOL_NAME: &str = "edit";
/// Canonical MultiEdit tool name. Matches `fn multi_edit` and the snake_case
/// convention used by every other multi-word tool name in the workspace
/// (`bash_output`, `bash_kill`).
pub const MULTIEDIT_TOOL_NAME: &str = "multi_edit";
/// Legacy MultiEdit tool name (pre-0.3.0 spelling). Still accepted as an
/// alias anywhere tool names are matched/dispatched, but deprecated.
#[deprecated(
    since = "0.3.0",
    note = "use MULTIEDIT_TOOL_NAME (\"multi_edit\"); the \"multiedit\" spelling will be removed in a future major release"
)]
pub const MULTIEDIT_TOOL_NAME_LEGACY: &str = "multiedit";

/// Returns `true` if `name` names the MultiEdit tool — either the canonical
/// `"multi_edit"` or the deprecated legacy `"multiedit"` spelling.
///
/// Pure predicate — no side effects, so it is safe for filtering,
/// configuration validation, or UI rendering. At dispatch points that accept
/// external input, use [`normalize_multi_edit_tool_name`] instead: it maps
/// both spellings to the canonical name and emits the one-time deprecation
/// warning when the legacy spelling is seen.
pub fn is_multi_edit_tool_name(name: &str) -> bool {
    #[allow(deprecated)]
    {
        name == MULTIEDIT_TOOL_NAME || name == MULTIEDIT_TOOL_NAME_LEGACY
    }
}

/// Resolves `name` to the canonical MultiEdit tool name (`"multi_edit"`), or
/// `None` when `name` is not a MultiEdit spelling.
///
/// When the deprecated legacy `"multiedit"` spelling is seen, the one-time
/// process-wide stderr deprecation warning fires (see
/// [`warn_legacy_multi_edit_tool_name`]). Use this helper at dispatch points
/// so both spellings keep working during the migration window; use
/// [`is_multi_edit_tool_name`] for side-effect-free queries.
pub fn normalize_multi_edit_tool_name(name: &str) -> Option<&'static str> {
    if name == MULTIEDIT_TOOL_NAME {
        return Some(MULTIEDIT_TOOL_NAME);
    }
    #[allow(deprecated)]
    if name == MULTIEDIT_TOOL_NAME_LEGACY {
        warn_legacy_multi_edit_tool_name();
        return Some(MULTIEDIT_TOOL_NAME);
    }
    None
}

/// Emits a one-time (per process) deprecation warning on stderr telling the
/// caller to migrate from `"multiedit"` to `"multi_edit"`. Subsequent calls
/// are no-ops, so dispatch loops do not spam logs.
pub fn warn_legacy_multi_edit_tool_name() {
    static ONCE: std::sync::Once = std::sync::Once::new();
    ONCE.call_once(|| {
        eprintln!(
            "[harness-write] DEPRECATION: tool name \"multiedit\" is deprecated; use \"multi_edit\". \
             The \"multiedit\" spelling will be removed in a future major release."
        );
    });
}

pub const WRITE_TOOL_DESCRIPTION: &str = "Create a new file, or overwrite an existing file.\n\nUsage:\n- New file (path does not exist): call Write directly. No prior Read is required.\n- Existing file: you must Read it first in this session, or Write fails with NOT_READ_THIS_SESSION.\n- Prefer Edit or MultiEdit for targeted changes to existing files.\n- Write is atomic: bytes land via a temporary file + rename.\n- Path must be absolute. If relative, it resolves against the session cwd.";

pub const EDIT_TOOL_DESCRIPTION: &str = "Replace exactly one occurrence of old_string with new_string in a file.\n\nUsage:\n- The file must have been Read first in this session.\n- old_string must match the file content exactly, character for character, including whitespace and indentation.\n- If old_string appears more than once, the call fails with OLD_STRING_NOT_UNIQUE.\n- If old_string does not match, the call fails with OLD_STRING_NOT_FOUND and returns the top fuzzy candidates.\n- Use dry_run: true to preview the unified diff without writing.\n- CRLF is normalized to LF on both sides.";

pub const MULTIEDIT_TOOL_DESCRIPTION: &str = "Apply a sequence of edits to a single file atomically.\n\nUsage:\n- edits is an ordered list of { old_string, new_string, replace_all? } objects.\n- Edits apply sequentially in memory: later edits see the output of earlier edits.\n- If any edit fails, none of the edits are applied and the file is untouched.\n- The file must have been Read first in this session.\n- Use dry_run: true to preview the final unified diff without writing.";