use pathfinder_common::error::PathfinderError;
use pathfinder_common::sandbox::Sandbox;
use pathfinder_common::types::{SemanticPath, VersionHash};
use rmcp::model::{ErrorCode, ErrorData};
use std::path::{Path, PathBuf};
pub(crate) fn pathfinder_to_error_data(err: &PathfinderError) -> ErrorData {
let data = match serde_json::to_value(err.to_error_response()) {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(
error = %e,
error_type = std::any::type_name::<PathfinderError>(),
"pathfinder_to_error_data: serialization failed, error context will be lost"
);
None
}
};
let error_code = match err {
pathfinder_common::error::PathfinderError::FileNotFound { .. }
| pathfinder_common::error::PathfinderError::FileAlreadyExists { .. }
| pathfinder_common::error::PathfinderError::SymbolNotFound { .. }
| pathfinder_common::error::PathfinderError::AmbiguousSymbol { .. }
| pathfinder_common::error::PathfinderError::InvalidSemanticPath { .. }
| pathfinder_common::error::PathfinderError::UnsupportedLanguage { .. }
| pathfinder_common::error::PathfinderError::InvalidTarget { .. }
| pathfinder_common::error::PathfinderError::TokenBudgetExceeded { .. }
| pathfinder_common::error::PathfinderError::MatchNotFound { .. }
| pathfinder_common::error::PathfinderError::AmbiguousMatch { .. }
| pathfinder_common::error::PathfinderError::TextNotFound { .. } => {
ErrorCode::INVALID_PARAMS
}
pathfinder_common::error::PathfinderError::AccessDenied { .. } => ErrorCode(-32001),
pathfinder_common::error::PathfinderError::VersionMismatch { .. } => ErrorCode(-32003),
pathfinder_common::error::PathfinderError::ValidationFailed { .. } => ErrorCode(-32004),
pathfinder_common::error::PathfinderError::IoError { .. }
| pathfinder_common::error::PathfinderError::ParseError { .. }
| pathfinder_common::error::PathfinderError::LspError { .. }
| pathfinder_common::error::PathfinderError::LspTimeout { .. }
| pathfinder_common::error::PathfinderError::NoLspAvailable { .. }
| pathfinder_common::error::PathfinderError::PathTraversal { .. } => {
ErrorCode::INTERNAL_ERROR
}
};
ErrorData::new(error_code, err.error_code(), data)
}
pub(crate) fn treesitter_error_to_error_data(e: pathfinder_treesitter::SurgeonError) -> ErrorData {
pathfinder_to_error_data(&e.into())
}
pub(crate) fn io_error_data(msg: impl Into<std::borrow::Cow<'static, str>>) -> ErrorData {
ErrorData::internal_error(msg, None)
}
pub(crate) fn check_occ(
base_version: &str,
current_hash: &VersionHash,
path: PathBuf,
) -> Result<(), ErrorData> {
if !current_hash.matches(base_version) {
return Err(pathfinder_to_error_data(
&PathfinderError::VersionMismatch {
path,
current_version_hash: current_hash.as_str().to_owned(),
lines_changed: None,
},
));
}
Ok(())
}
pub(crate) fn check_sandbox_access(
sandbox: &Sandbox,
relative_path: &Path,
tool_name: &str,
raw_semantic_path: &str,
) -> Result<(), ErrorData> {
if let Err(e) = sandbox.check(relative_path) {
tracing::warn!(
tool = tool_name,
semantic_path = raw_semantic_path,
error = %e,
"{tool_name}: access denied"
);
return Err(pathfinder_to_error_data(&e));
}
Ok(())
}
pub(crate) fn language_from_path(path: &Path) -> String {
match path.extension().and_then(|e| e.to_str()) {
Some("ts" | "tsx") => "typescript",
Some("js" | "jsx" | "mjs" | "cjs") => "javascript",
Some("rs") => "rust",
Some("go") => "go",
Some("py") => "python",
Some("json") => "json",
Some("yaml" | "yml") => "yaml",
Some("toml") => "toml",
Some("md" | "mdx") => "markdown",
Some("sh" | "bash") => "shell",
Some("dockerfile") | None
if path.file_name().and_then(|n| n.to_str()) == Some("Dockerfile") =>
{
"dockerfile"
}
_ => "text",
}
.to_owned()
}
pub(crate) fn parse_semantic_path(raw: &str) -> Result<SemanticPath, ErrorData> {
SemanticPath::parse(raw).ok_or_else(|| {
pathfinder_to_error_data(&PathfinderError::InvalidSemanticPath {
input: raw.to_owned(),
issue: "Semantic path is malformed or missing '::' separator.".to_owned(),
})
})
}
pub(crate) fn require_symbol_target(
semantic_path: &SemanticPath,
raw_path: &str,
) -> Result<(), ErrorData> {
if semantic_path.is_bare_file() {
return Err(pathfinder_to_error_data(
&PathfinderError::InvalidSemanticPath {
input: raw_path.to_owned(),
issue: "this tool requires a symbol target — use 'file.rs::symbol' format"
.to_owned(),
},
));
}
Ok(())
}
pub(crate) fn serialize_metadata<T: serde::Serialize>(metadata: &T) -> Option<serde_json::Value> {
match serde_json::to_value(metadata) {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(
error = %e,
type_name = std::any::type_name::<T>(),
"structured metadata serialization failed; agent will receive null"
);
None
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use pathfinder_common::error::{PathfinderError, SandboxTier};
use rmcp::model::ErrorCode;
#[test]
fn test_error_code_mapping_client_errors_to_invalid_params() {
let client_errors = vec![
PathfinderError::FileNotFound {
path: "src/main.rs".into(),
},
PathfinderError::FileAlreadyExists {
path: "src/main.rs".into(),
},
PathfinderError::SymbolNotFound {
semantic_path: "src/auth.ts::login".into(),
did_you_mean: vec![],
},
PathfinderError::AmbiguousSymbol {
semantic_path: "src/auth.ts::login".into(),
matches: vec![],
},
PathfinderError::InvalidSemanticPath {
input: "invalid".into(),
issue: "missing ::".into(),
},
PathfinderError::UnsupportedLanguage {
path: "data.xyz".into(),
},
PathfinderError::InvalidTarget {
semantic_path: "src/lib.rs::CONST".into(),
reason: "not a block construct".into(),
edit_index: None,
valid_edit_types: None,
},
PathfinderError::TokenBudgetExceeded {
used: 1000,
budget: 500,
},
PathfinderError::MatchNotFound {
filepath: "config.yaml".into(),
},
PathfinderError::AmbiguousMatch {
filepath: "config.yaml".into(),
occurrences: 2,
},
PathfinderError::TextNotFound {
filepath: "src/main.rs".into(),
old_text: "fn main()".into(),
context_line: 10,
actual_content: None,
closest_match: None,
},
];
for err in client_errors {
let error_data = pathfinder_to_error_data(&err);
assert_eq!(
error_data.code,
ErrorCode::INVALID_PARAMS,
"Expected INVALID_PARAMS for error: {}",
err.error_code()
);
}
}
#[test]
fn test_error_code_mapping_access_denied_to_custom_code() {
let err = PathfinderError::AccessDenied {
path: ".env".into(),
tier: SandboxTier::HardcodedDeny,
};
let error_data = pathfinder_to_error_data(&err);
assert_eq!(error_data.code, ErrorCode(-32001));
}
#[test]
fn test_error_code_mapping_version_mismatch_to_custom_code() {
let err = PathfinderError::VersionMismatch {
path: "src/main.rs".into(),
current_version_hash: "sha256:abc123".into(),
lines_changed: None,
};
let error_data = pathfinder_to_error_data(&err);
assert_eq!(error_data.code, ErrorCode(-32003));
}
#[test]
fn test_error_code_mapping_validation_failed_to_custom_code() {
let err = PathfinderError::ValidationFailed {
count: 2,
introduced_errors: vec![],
};
let error_data = pathfinder_to_error_data(&err);
assert_eq!(error_data.code, ErrorCode(-32004));
}
#[test]
fn test_error_code_mapping_internal_errors_to_internal_error() {
let internal_errors = vec![
PathfinderError::IoError {
message: "disk full".into(),
},
PathfinderError::ParseError {
path: "src/main.rs".into(),
reason: "unexpected token".into(),
},
PathfinderError::LspError {
message: "LSP crashed".into(),
},
PathfinderError::LspTimeout { timeout_ms: 5000 },
PathfinderError::NoLspAvailable {
language: "ruby".into(),
},
];
for err in internal_errors {
let error_data = pathfinder_to_error_data(&err);
assert_eq!(
error_data.code,
ErrorCode::INTERNAL_ERROR,
"Expected INTERNAL_ERROR for error: {}",
err.error_code()
);
}
}
#[test]
fn test_check_occ_full_hash_match() {
let hash = VersionHash::compute(b"hello world");
let result = check_occ(hash.as_str(), &hash, PathBuf::from("test.rs"));
assert!(result.is_ok());
}
#[test]
fn test_check_occ_short_7_char_prefix_matches() {
let hash = VersionHash::compute(b"hello world");
let short_with_prefix = &hash.as_str()[..14]; let result = check_occ(short_with_prefix, &hash, PathBuf::from("test.rs"));
assert!(
result.is_ok(),
"7-char prefix with sha256: should be accepted"
);
let short_no_prefix = hash.short();
let result2 = check_occ(short_no_prefix, &hash, PathBuf::from("test.rs"));
assert!(
result2.is_ok(),
"7-char no-prefix short hash must be accepted"
);
}
#[test]
fn test_check_occ_wrong_prefix_fails() {
let hash = VersionHash::compute(b"hello world");
let result = check_occ("sha256:0000000", &hash, PathBuf::from("test.rs"));
assert!(result.is_err(), "wrong prefix must fail");
}
#[test]
fn test_check_occ_prefix_too_short_is_rejected() {
let hash = VersionHash::compute(b"hello world");
let result = check_occ("sha256:4ec", &hash, PathBuf::from("test.rs")); assert!(result.is_err(), "prefix < 7 hex chars must be rejected");
}
#[test]
fn test_check_occ_full_hash_mismatch_fails() {
let hash_a = VersionHash::compute(b"hello world");
let hash_b = VersionHash::compute(b"different content");
let result = check_occ(hash_a.as_str(), &hash_b, PathBuf::from("test.rs"));
assert!(result.is_err());
}
#[test]
fn test_check_occ_8_char_prefix_matches() {
let hash = VersionHash::compute(b"hello world");
let prefix_8 = &hash.as_str()[..15]; let result = check_occ(prefix_8, &hash, PathBuf::from("test.rs"));
assert!(result.is_ok(), "8-char prefix should also be accepted");
}
#[test]
fn test_surgeon_file_not_found_maps_to_invalid_params() {
use pathfinder_treesitter::SurgeonError;
let surgeon_err = SurgeonError::FileNotFound("src/does_not_exist.rs".into());
let pf_err: pathfinder_common::error::PathfinderError = surgeon_err.into();
let error_data = pathfinder_to_error_data(&pf_err);
assert_eq!(
error_data.code,
ErrorCode::INVALID_PARAMS,
"missing file must be INVALID_PARAMS, not INTERNAL_ERROR"
);
let code_str = error_data
.data
.as_ref()
.and_then(|d| d.get("error"))
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(code_str, "FILE_NOT_FOUND");
}
#[test]
fn test_serialize_metadata_success() {
use std::collections::HashMap;
let mut map = HashMap::new();
map.insert("key", "value");
let result = super::serialize_metadata(&map);
assert!(result.is_some());
}
}