use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, thiserror::Error)]
pub enum PathfinderError {
#[error("file not found: {path}")]
FileNotFound { path: PathBuf },
#[error("file already exists: {path}")]
FileAlreadyExists { path: PathBuf },
#[error("symbol not found: {semantic_path}")]
SymbolNotFound {
semantic_path: String,
did_you_mean: Vec<String>,
},
#[error("invalid semantic path: {input}")]
InvalidSemanticPath { input: String, issue: String },
#[error("ambiguous symbol: {semantic_path}")]
AmbiguousSymbol {
semantic_path: String,
matches: Vec<String>,
},
#[error("version mismatch for {path}")]
VersionMismatch {
path: PathBuf,
current_version_hash: String,
lines_changed: Option<String>,
},
#[error("validation failed: {count} new errors introduced")]
ValidationFailed {
count: usize,
introduced_errors: Vec<DiagnosticError>,
},
#[error("no LSP available for language: {language}")]
NoLspAvailable { language: String },
#[error("LSP error: {message}")]
LspError { message: String },
#[error("I/O error: {message}")]
IoError { message: String },
#[error("LSP timeout after {timeout_ms}ms")]
LspTimeout { timeout_ms: u64 },
#[error("access denied: {path}")]
AccessDenied { path: PathBuf, tier: SandboxTier },
#[error("parse error in {path}: {reason}")]
ParseError { path: PathBuf, reason: String },
#[error("unsupported language for target file: {path}")]
UnsupportedLanguage { path: PathBuf },
#[error("invalid target: {reason}")]
InvalidTarget {
semantic_path: String,
reason: String,
edit_index: Option<usize>,
valid_edit_types: Option<Vec<String>>,
},
#[error("token budget exceeded: {used} / {budget}")]
TokenBudgetExceeded { used: usize, budget: usize },
#[error("match not found: old_text not present in file")]
MatchNotFound { filepath: PathBuf },
#[error("ambiguous match: old_text found {occurrences} times")]
AmbiguousMatch {
filepath: PathBuf,
occurrences: usize,
},
#[error("text not found: '{old_text}' not found within ยฑ25 lines of line {context_line} in {filepath}")]
TextNotFound {
filepath: PathBuf,
old_text: String,
context_line: u32,
actual_content: Option<String>,
closest_match: Option<String>,
},
#[error("path traversal rejected: {path} escapes workspace root {workspace_root}")]
PathTraversal {
path: PathBuf,
workspace_root: PathBuf,
},
}
impl PathfinderError {
#[must_use]
pub fn error_code(&self) -> &'static str {
match self {
Self::FileNotFound { .. } => "FILE_NOT_FOUND",
Self::FileAlreadyExists { .. } => "FILE_ALREADY_EXISTS",
Self::SymbolNotFound { .. } => "SYMBOL_NOT_FOUND",
Self::AmbiguousSymbol { .. } => "AMBIGUOUS_SYMBOL",
Self::VersionMismatch { .. } => "VERSION_MISMATCH",
Self::ValidationFailed { .. } => "VALIDATION_FAILED",
Self::NoLspAvailable { .. } => "NO_LSP_AVAILABLE",
Self::LspError { .. } => "LSP_ERROR",
Self::LspTimeout { .. } => "LSP_TIMEOUT",
Self::AccessDenied { .. } => "ACCESS_DENIED",
Self::IoError { .. } => "INTERNAL_ERROR",
Self::ParseError { .. } => "PARSE_ERROR",
Self::UnsupportedLanguage { .. } => "UNSUPPORTED_LANGUAGE",
Self::InvalidTarget { .. } => "INVALID_TARGET",
Self::TokenBudgetExceeded { .. } => "TOKEN_BUDGET_EXCEEDED",
Self::MatchNotFound { .. } => "MATCH_NOT_FOUND",
Self::AmbiguousMatch { .. } => "AMBIGUOUS_MATCH",
Self::TextNotFound { .. } => "TEXT_NOT_FOUND",
Self::InvalidSemanticPath { .. } => "INVALID_SEMANTIC_PATH",
Self::PathTraversal { .. } => "PATH_TRAVERSAL",
}
}
#[must_use]
pub fn hint(&self) -> Option<String> {
match self {
Self::SymbolNotFound { did_you_mean, .. } => {
if did_you_mean.is_empty() {
Some("Use read_source_file to see available symbols in this file.".to_owned())
} else {
Some(format!(
"Did you mean: {}? Use read_source_file to see available symbols.",
did_you_mean.join(", ")
))
}
}
Self::InvalidTarget { valid_edit_types, .. } => {
if valid_edit_types.is_some() {
Some("Set edit_type to one of: 'replace_body', 'replace_full', 'insert_before', 'insert_after', 'delete'. Or set old_text + context_line for text-based targeting.".to_owned())
} else {
Some("replace_body requires a block-bodied construct. For constants, use replace_full.".to_owned())
}
}
Self::VersionMismatch { .. } => Some(
"The file was modified. Use the new hash to retry your edit if the changes \
do not overlap with your target."
.to_owned(),
),
Self::AccessDenied { .. } => {
Some("File is outside workspace sandbox. Check .pathfinderignore rules.".to_owned())
}
Self::UnsupportedLanguage { .. } => Some(
"No tree-sitter grammar for this file type. Use read_file and write_file instead."
.to_owned(),
),
Self::FileNotFound { .. } => Some(
"Verify the file path is relative to the workspace root and the file exists."
.to_owned(),
),
Self::ValidationFailed { .. } => Some(
"Set ignore_validation_failures=true to write despite errors, or fix the \
introduced errors before retrying."
.to_owned(),
),
Self::MatchNotFound { .. } => Some(
"The old_text was not found in the file. Use read_file to verify the exact text \
before retrying."
.to_owned(),
),
Self::AmbiguousMatch { occurrences, .. } => Some(format!(
"old_text matched {occurrences} times. Make it more specific or use \
replace_batch with a semantic_path to target a single symbol."
)),
Self::TextNotFound { context_line, closest_match, .. } => {
let base = format!(
"The old_text was not found within ยฑ25 lines of line {context_line}. \
Use read_source_file to verify the exact text and adjust context_line."
);
if let Some(candidate) = closest_match {
Some(format!("{base} Closest match found: '{candidate}'."))
} else {
Some(base)
}
}
Self::InvalidSemanticPath { input, .. } => Some(format!(
"'{input}' is missing the file path โ did you mean 'crates/.../file.rs::{input}'? \
Semantic paths must include the file path and '::' separator (e.g., 'src/auth.ts::AuthService.login')."
)),
Self::PathTraversal { .. } => Some(
"Path traversal is not allowed. Use a relative path without '..' components or absolute paths."
.to_owned(),
),
_ => None,
}
}
#[must_use]
pub fn to_error_response(&self) -> ErrorResponse {
ErrorResponse {
error: self.error_code().to_owned(),
message: self.to_string(),
details: self.to_details(),
hint: self.hint(),
}
}
fn to_details(&self) -> serde_json::Value {
match self {
Self::SymbolNotFound { did_you_mean, .. } => {
serde_json::json!({ "did_you_mean": did_you_mean })
}
Self::AmbiguousSymbol { matches, .. } => {
serde_json::json!({ "matches": matches })
}
Self::VersionMismatch {
current_version_hash,
lines_changed,
..
} => {
serde_json::json!({
"current_version_hash": current_version_hash,
"lines_changed": lines_changed,
})
}
Self::ValidationFailed {
introduced_errors, ..
} => {
serde_json::json!({ "introduced_errors": introduced_errors })
}
Self::AmbiguousMatch { occurrences, .. } => {
serde_json::json!({ "occurrences": occurrences })
}
Self::AccessDenied { tier, .. } => {
serde_json::json!({ "tier": tier })
}
Self::TokenBudgetExceeded { used, budget } => {
serde_json::json!({ "used": used, "budget": budget })
}
Self::InvalidSemanticPath { issue, .. } => {
serde_json::json!({ "issue": issue })
}
Self::InvalidTarget {
edit_index,
valid_edit_types,
..
} => {
let mut map = serde_json::Map::new();
if let Some(idx) = edit_index {
map.insert("edit_index".to_string(), serde_json::json!(idx));
}
if let Some(types) = valid_edit_types {
map.insert("valid_edit_types".to_string(), serde_json::json!(types));
}
serde_json::Value::Object(map)
}
Self::TextNotFound {
filepath,
old_text,
context_line,
actual_content,
closest_match,
} => {
let mut map = serde_json::Map::new();
map.insert("filepath".to_string(), serde_json::json!(filepath));
map.insert("old_text".to_string(), serde_json::json!(old_text));
map.insert("context_line".to_string(), serde_json::json!(context_line));
if let Some(content) = actual_content {
map.insert("actual_content".to_string(), serde_json::json!(content));
}
if let Some(candidate) = closest_match {
map.insert("closest_match".to_string(), serde_json::json!(candidate));
}
serde_json::Value::Object(map)
}
Self::PathTraversal {
path,
workspace_root,
} => {
serde_json::json!({ "path": path, "workspace_root": workspace_root })
}
_ => serde_json::Value::Object(serde_json::Map::new()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorResponse {
pub error: String,
pub message: String,
pub details: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
}
#[must_use]
pub fn compute_lines_changed(old_content: &str, new_content: &str) -> String {
let old_lines = old_content.lines().count();
let new_lines = new_content.lines().count();
let added = new_lines.saturating_sub(old_lines);
let removed = old_lines.saturating_sub(new_lines);
format!("+{added}/-{removed}")
}
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct DiagnosticError {
pub severity: u8,
pub code: String,
pub message: String,
pub file: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum SandboxTier {
HardcodedDeny,
DefaultDeny,
UserDefined,
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_error_code_mapping() {
let err = PathfinderError::FileNotFound {
path: "src/main.rs".into(),
};
assert_eq!(err.error_code(), "FILE_NOT_FOUND");
let err = PathfinderError::SymbolNotFound {
semantic_path: "src/auth.ts::AuthService.login".into(),
did_you_mean: vec!["AuthService.logout".into()],
};
assert_eq!(err.error_code(), "SYMBOL_NOT_FOUND");
}
#[test]
fn test_error_response_serialization() {
let err = PathfinderError::VersionMismatch {
path: "src/main.rs".into(),
current_version_hash: "sha256:abc123".into(),
lines_changed: Some("+5/-2".into()),
};
let response = err.to_error_response();
assert_eq!(response.error, "VERSION_MISMATCH");
assert_eq!(response.details["current_version_hash"], "sha256:abc123");
assert_eq!(response.details["lines_changed"], "+5/-2");
assert!(
response.hint.is_some(),
"VERSION_MISMATCH should carry a hint"
);
let json = serde_json::to_string(&response).expect("serialization should succeed");
let deserialized: ErrorResponse =
serde_json::from_str(&json).expect("deserialization should succeed");
assert_eq!(deserialized.error, "VERSION_MISMATCH");
}
#[test]
fn test_all_error_codes_are_screaming_snake_case() {
let errors: Vec<PathfinderError> = vec![
PathfinderError::FileNotFound { path: "a".into() },
PathfinderError::FileAlreadyExists { path: "a".into() },
PathfinderError::SymbolNotFound {
semantic_path: "a".into(),
did_you_mean: vec![],
},
PathfinderError::AmbiguousSymbol {
semantic_path: "a".into(),
matches: vec![],
},
PathfinderError::VersionMismatch {
path: "a".into(),
current_version_hash: "x".into(),
lines_changed: None,
},
PathfinderError::ValidationFailed {
count: 0,
introduced_errors: vec![],
},
PathfinderError::NoLspAvailable {
language: "a".into(),
},
PathfinderError::LspError {
message: "a".into(),
},
PathfinderError::LspTimeout { timeout_ms: 0 },
PathfinderError::AccessDenied {
path: "a".into(),
tier: SandboxTier::HardcodedDeny,
},
PathfinderError::ParseError {
path: "a".into(),
reason: "a".into(),
},
PathfinderError::UnsupportedLanguage { path: "a".into() },
PathfinderError::InvalidTarget {
semantic_path: "a".into(),
reason: "a".into(),
edit_index: None,
valid_edit_types: None,
},
PathfinderError::TokenBudgetExceeded { used: 0, budget: 0 },
PathfinderError::MatchNotFound {
filepath: "a".into(),
},
PathfinderError::AmbiguousMatch {
filepath: "a".into(),
occurrences: 0,
},
PathfinderError::IoError {
message: "disk full".into(),
},
PathfinderError::TextNotFound {
filepath: "a.vue".into(),
old_text: "<button>Check</button>".into(),
context_line: 42,
actual_content: None,
closest_match: None,
},
PathfinderError::InvalidSemanticPath {
input: "send".into(),
issue: "missing ::".into(),
},
];
for err in &errors {
let code = err.error_code();
assert!(
code.chars().all(|c| c.is_ascii_uppercase() || c == '_'),
"Error code '{code}' is not SCREAMING_SNAKE_CASE"
);
}
}
#[test]
fn test_symbol_not_found_details_include_did_you_mean() {
let err = PathfinderError::SymbolNotFound {
semantic_path: "src/auth.ts::startServer".into(),
did_you_mean: vec!["stopServer".into(), "startService".into()],
};
let response = err.to_error_response();
let suggestions = response.details["did_you_mean"]
.as_array()
.expect("did_you_mean should be an array");
assert_eq!(suggestions.len(), 2);
}
#[test]
fn test_compute_lines_changed_lines_added() {
let old = "line1\nline2";
let new = "line1\nline2\nline3\nline4\nline5";
assert_eq!(compute_lines_changed(old, new), "+3/-0");
}
#[test]
fn test_compute_lines_changed_lines_removed() {
let old = "a\nb\nc\nd";
let new = "a\nb";
assert_eq!(compute_lines_changed(old, new), "+0/-2");
}
#[test]
fn test_compute_lines_changed_mixed() {
let old = "a\nb\nc";
let new = "a\nb\nc\nd";
assert_eq!(compute_lines_changed(old, new), "+1/-0");
}
#[test]
fn test_compute_lines_changed_identical() {
let content = "same\ncontent\nhere";
assert_eq!(compute_lines_changed(content, content), "+0/-0");
}
#[test]
fn test_compute_lines_changed_empty_to_nonempty() {
assert_eq!(compute_lines_changed("", "a\nb\nc"), "+3/-0");
}
#[test]
fn test_version_mismatch_hint_is_present() {
let err = PathfinderError::VersionMismatch {
path: "src/lib.rs".into(),
current_version_hash: "sha256:new".into(),
lines_changed: Some("+2/-1".into()),
};
let hint = err.hint().expect("VERSION_MISMATCH should have a hint");
assert!(
hint.contains("new hash"),
"hint should mention re-reading: {hint}"
);
}
#[test]
fn test_symbol_not_found_hint_with_suggestions() {
let err = PathfinderError::SymbolNotFound {
semantic_path: "src/auth.ts::login".into(),
did_you_mean: vec!["logout".into(), "logIn".into()],
};
let hint = err.hint().expect("should have hint");
assert!(
hint.contains("logout"),
"hint should include suggestions: {hint}"
);
assert!(
hint.contains("logIn"),
"hint should include all suggestions: {hint}"
);
}
#[test]
fn test_symbol_not_found_hint_without_suggestions() {
let err = PathfinderError::SymbolNotFound {
semantic_path: "src/auth.ts::unknown".into(),
did_you_mean: vec![],
};
let hint = err
.hint()
.expect("should have hint even without suggestions");
assert!(
hint.contains("read_source_file"),
"hint should point to read_source_file: {hint}"
);
}
#[test]
fn test_access_denied_hint_mentions_sandbox() {
let err = PathfinderError::AccessDenied {
path: ".env".into(),
tier: SandboxTier::HardcodedDeny,
};
let hint = err.hint().expect("ACCESS_DENIED should have a hint");
assert!(
hint.contains("sandbox"),
"hint should mention sandbox: {hint}"
);
}
#[test]
fn test_unsupported_language_hint_mentions_write_file() {
let err = PathfinderError::UnsupportedLanguage {
path: "data.xyz".into(),
};
let hint = err.hint().expect("UNSUPPORTED_LANGUAGE should have a hint");
assert!(
hint.contains("write_file"),
"hint should mention write_file: {hint}"
);
}
#[test]
fn test_validation_failed_hint_mentions_ignore_flag() {
let err = PathfinderError::ValidationFailed {
count: 2,
introduced_errors: vec![],
};
let hint = err.hint().expect("VALIDATION_FAILED should have a hint");
assert!(
hint.contains("ignore_validation_failures"),
"hint should mention the flag: {hint}"
);
}
#[test]
fn test_match_not_found_hint_mentions_read_file() {
let err = PathfinderError::MatchNotFound {
filepath: "config.yaml".into(),
};
let hint = err.hint().expect("MATCH_NOT_FOUND should have a hint");
assert!(
hint.contains("read_file"),
"hint should mention read_file: {hint}"
);
}
#[test]
fn test_hint_serialized_in_error_response() {
let err = PathfinderError::InvalidTarget {
semantic_path: "src/lib.rs::CONST".into(),
reason: "not a block construct".into(),
edit_index: None,
valid_edit_types: None,
};
let resp = err.to_error_response();
assert!(
resp.hint.is_some(),
"hint must be serialized in ErrorResponse"
);
let json = serde_json::to_value(&resp).expect("serialize");
assert!(
json.get("hint").is_some(),
"hint must appear in JSON output"
);
}
#[test]
fn test_text_not_found_hint_mentions_context_line() {
let err = PathfinderError::TextNotFound {
filepath: "src/component.vue".into(),
old_text: "<button>Check</button>".to_owned(),
context_line: 42,
actual_content: None,
closest_match: None,
};
assert_eq!(err.error_code(), "TEXT_NOT_FOUND");
let hint = err.hint().expect("TEXT_NOT_FOUND should have a hint");
assert!(
hint.contains("42"),
"hint should mention context_line: {hint}"
);
assert!(
hint.contains("read_source_file"),
"hint should reference read_source_file: {hint}"
);
}
#[test]
fn test_text_not_found_hint_with_closest_match() {
let err = PathfinderError::TextNotFound {
filepath: "src/auth.ts".into(),
old_text: "const x = 1;".to_owned(),
context_line: 10,
actual_content: None,
closest_match: Some("const x = 2;".to_owned()),
};
let hint = err
.hint()
.expect("TEXT_NOT_FOUND with closest_match should have a hint");
assert!(
hint.contains("const x = 2;"),
"hint should include the closest match candidate: {hint}"
);
let response = err.to_error_response();
assert_eq!(response.details["closest_match"], "const x = 2;");
}
#[test]
fn test_path_traversal_error() {
let err = PathfinderError::PathTraversal {
path: "../../etc/passwd".into(),
workspace_root: "/workspace".into(),
};
assert_eq!(err.error_code(), "PATH_TRAVERSAL");
let hint = err.hint().expect("PATH_TRAVERSAL should have a hint");
assert!(
hint.contains("not allowed"),
"hint should explain traversal is not allowed: {hint}"
);
let response = err.to_error_response();
assert_eq!(response.error, "PATH_TRAVERSAL");
assert_eq!(response.details["path"], "../../etc/passwd");
assert_eq!(response.details["workspace_root"], "/workspace");
}
}