use std::path::Path;
use std::sync::LazyLock;
use regex::Regex;
use rmcp::ErrorData as McpError;
#[allow(clippy::unwrap_used)] static RANGE_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[\w\-/.~^@]+(\.\.\.?[\w\-/.~^@]+)?$").unwrap());
#[allow(clippy::unwrap_used)] static JIRA_KEY_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[A-Z][A-Z0-9_]+-\d+$").unwrap());
#[allow(clippy::unwrap_used)] static CONFLUENCE_ID_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\d+$").unwrap());
pub fn validate_range(range: &str) -> Result<(), McpError> {
if range.is_empty() {
return Err(McpError::invalid_params(
"range must not be empty".to_string(),
None,
));
}
if !RANGE_PATTERN.is_match(range) {
return Err(McpError::invalid_params(
format!("range {range:?} is not a well-formed git range specifier"),
None,
));
}
Ok(())
}
pub fn validate_repo_path(repo_path: &str) -> Result<(), McpError> {
let path = Path::new(repo_path);
if !path.is_absolute() {
return Err(McpError::invalid_params(
format!("repo_path {repo_path:?} must be an absolute path"),
None,
));
}
if !path.exists() {
return Err(McpError::invalid_params(
format!("repo_path {repo_path:?} does not exist"),
None,
));
}
if !path.is_dir() {
return Err(McpError::invalid_params(
format!("repo_path {repo_path:?} is not a directory"),
None,
));
}
Ok(())
}
pub fn validate_jira_key(key: &str) -> Result<(), McpError> {
if !JIRA_KEY_PATTERN.is_match(key) {
return Err(McpError::invalid_params(
format!("jira key {key:?} is not a well-formed issue key (expected e.g. ABC-123)"),
None,
));
}
Ok(())
}
pub fn validate_confluence_id(id: &str) -> Result<(), McpError> {
if !CONFLUENCE_ID_PATTERN.is_match(id) {
return Err(McpError::invalid_params(
format!("confluence id {id:?} must be numeric"),
None,
));
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn range_accepts_single_ref() {
validate_range("HEAD").unwrap();
validate_range("main").unwrap();
validate_range("origin/main").unwrap();
validate_range("abc1234").unwrap();
}
#[test]
fn range_accepts_revision_selectors() {
validate_range("HEAD~3").unwrap();
validate_range("HEAD^").unwrap();
validate_range("HEAD@").unwrap();
validate_range("v1.2.3").unwrap();
}
#[test]
fn range_accepts_two_and_three_dot_ranges() {
validate_range("HEAD~3..HEAD").unwrap();
validate_range("main...feature").unwrap();
validate_range("abc123..def456").unwrap();
}
#[test]
fn range_rejects_empty() {
let err = validate_range("").unwrap_err();
assert!(err.message.contains("must not be empty"));
}
#[test]
fn range_rejects_shell_metacharacters() {
assert!(validate_range("HEAD; rm -rf /").is_err());
assert!(validate_range("HEAD && evil").is_err());
assert!(validate_range("`id`").is_err());
assert!(validate_range("HEAD\nmain").is_err());
assert!(validate_range("HEAD main").is_err());
}
#[test]
fn range_rejects_whitespace_and_control_chars() {
assert!(validate_range(" HEAD").is_err());
assert!(validate_range("HEAD ").is_err());
assert!(validate_range("HE AD").is_err());
assert!(validate_range("HEAD\t").is_err());
}
#[test]
fn repo_path_rejects_relative_path() {
let err = validate_repo_path("./some/where").unwrap_err();
assert!(err.message.contains("must be an absolute path"));
let err = validate_repo_path("relative").unwrap_err();
assert!(err.message.contains("must be an absolute path"));
}
#[test]
fn repo_path_rejects_nonexistent_absolute_path() {
let err = validate_repo_path("/this/path/does/not/exist/omnidev").unwrap_err();
assert!(err.message.contains("does not exist"));
}
#[test]
fn repo_path_rejects_file_not_dir() {
let file = tempfile::NamedTempFile::new().unwrap();
let err = validate_repo_path(file.path().to_str().unwrap()).unwrap_err();
assert!(err.message.contains("is not a directory"));
}
#[test]
fn repo_path_accepts_existing_directory() {
let dir = tempfile::tempdir().unwrap();
validate_repo_path(dir.path().to_str().unwrap()).unwrap();
}
#[test]
fn jira_key_accepts_well_formed_keys() {
validate_jira_key("ABC-123").unwrap();
validate_jira_key("PROJ1-1").unwrap();
validate_jira_key("A_B-99").unwrap();
}
#[test]
fn jira_key_rejects_malformed_keys() {
assert!(validate_jira_key("abc-123").is_err());
assert!(validate_jira_key("A-123").is_err()); assert!(validate_jira_key("ABC123").is_err());
assert!(validate_jira_key("ABC-").is_err());
assert!(validate_jira_key("").is_err());
assert!(validate_jira_key("ABC-12a").is_err());
}
#[test]
fn confluence_id_accepts_digits() {
validate_confluence_id("123").unwrap();
validate_confluence_id("987654321").unwrap();
}
#[test]
fn confluence_id_rejects_non_numeric() {
assert!(validate_confluence_id("").is_err());
assert!(validate_confluence_id("abc").is_err());
assert!(validate_confluence_id("123abc").is_err());
assert!(validate_confluence_id("12 34").is_err());
}
}