use crate::domain::MAX_TITLE_LENGTH;
pub fn validate_prefix(s: &str) -> Result<String, String> {
use crate::commands::init;
let trimmed = s.trim();
init::validate_prefix(trimmed).map_err(|e| e.to_string())?;
Ok(trimmed.to_string())
}
pub fn validate_issue_id(s: &str) -> Result<String, String> {
let s = s.trim();
if s.is_empty() {
return Err("Issue ID cannot be empty".to_string());
}
let parts: Vec<&str> = s.splitn(2, '-').collect();
if parts.len() != 2 {
return Err(format!(
"Invalid issue ID format: '{}'. Expected format: prefix-suffix (e.g., proj-abc or proj-abc-123)",
s
));
}
let prefix = parts[0];
let suffix = parts[1];
validate_prefix(prefix).map_err(|e| format!("Issue ID {}", e.to_lowercase()))?;
if suffix.is_empty() {
return Err("Issue ID suffix cannot be empty".to_string());
}
if !suffix
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-')
{
return Err("Issue ID suffix must contain only alphanumerics and hyphens".to_string());
}
if suffix.starts_with('-') {
return Err("Issue ID suffix cannot start with a hyphen".to_string());
}
if suffix.ends_with('-') {
return Err("Issue ID suffix cannot end with a hyphen".to_string());
}
if suffix.contains("--") {
return Err("Issue ID suffix cannot contain consecutive hyphens".to_string());
}
Ok(s.to_string())
}
pub fn validate_title(s: &str) -> Result<String, String> {
let s = s.trim();
if s.is_empty() {
return Err("Title cannot be empty".to_string());
}
if s.len() > MAX_TITLE_LENGTH {
return Err(format!(
"Title cannot exceed {} characters, got {} characters",
MAX_TITLE_LENGTH,
s.len()
));
}
if s.contains('\n') || s.contains('\r') {
return Err("Title cannot contain newline characters".to_string());
}
if let Some(pos) = s.chars().position(|c| {
let code = c as u32;
(code < 0x20 && code != 0x09) || (0x7F..=0x9F).contains(&code)
}) {
return Err(format!(
"Title contains invalid control character at position {}",
pos
));
}
Ok(s.to_string())
}
fn validate_text_field(s: &str, field_name: &str) -> Result<String, String> {
if let Some(pos) = s.chars().position(|c| {
let code = c as u32;
(code < 0x20 && code != 0x09 && code != 0x0A && code != 0x0D)
|| (0x7F..=0x9F).contains(&code)
}) {
return Err(format!(
"{} contains invalid control character at position {}",
field_name, pos
));
}
Ok(s.to_string())
}
pub fn validate_description(s: &str) -> Result<String, String> {
validate_text_field(s, "Description")
}
pub const MAX_LABEL_LENGTH: usize = 50;
pub fn validate_label(s: &str) -> Result<String, String> {
let s = s.trim();
if s.is_empty() {
return Err("Label cannot be empty".to_string());
}
if s.len() > MAX_LABEL_LENGTH {
return Err(format!(
"Label cannot exceed {} characters, got {} characters",
MAX_LABEL_LENGTH,
s.len()
));
}
if !s
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
{
if s.chars().any(|c| c.is_ascii_uppercase()) {
return Err(
"Label must be lowercase. Use hyphens or underscores instead of spaces".to_string(),
);
}
return Err(
"Label must contain only lowercase letters, numbers, hyphens, and underscores"
.to_string(),
);
}
if let Some(first) = s.chars().next() {
if !first.is_ascii_alphanumeric() {
return Err("Label must start with a letter or number".to_string());
}
}
if let Some(last) = s.chars().last() {
if !last.is_ascii_alphanumeric() {
return Err("Label must end with a letter or number".to_string());
}
}
if s.contains("--") {
return Err("Label cannot contain consecutive hyphens".to_string());
}
if s.contains("__") {
return Err("Label cannot contain consecutive underscores".to_string());
}
Ok(s.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[test]
fn test_validate_prefix_valid() {
assert!(validate_prefix("proj").is_ok());
assert!(validate_prefix("rivets").is_ok());
assert!(validate_prefix("AB").is_ok());
assert!(validate_prefix("test123").is_ok());
assert!(validate_prefix("a1b2c3d4e5f6g7h8i9j0").is_ok()); }
#[test]
fn test_validate_prefix_too_short() {
let result = validate_prefix("a");
assert!(result.is_err());
assert!(result.unwrap_err().contains("at least 2 characters"));
}
#[test]
fn test_validate_prefix_too_long() {
let result = validate_prefix("a".repeat(21).as_str());
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot exceed 20"));
}
#[test]
fn test_validate_prefix_invalid_chars() {
assert!(validate_prefix("proj-test").is_err()); assert!(validate_prefix("proj_test").is_err()); assert!(validate_prefix("proj test").is_err()); assert!(validate_prefix("proj.test").is_err()); }
#[test]
fn test_validate_prefix_trims_whitespace() {
assert_eq!(validate_prefix(" proj ").unwrap(), "proj");
}
#[test]
fn test_validate_issue_id_valid() {
assert!(validate_issue_id("proj-abc").is_ok());
assert!(validate_issue_id("rivets-123").is_ok());
assert!(validate_issue_id("ab-1").is_ok());
assert!(validate_issue_id("TEST-xyz").is_ok());
}
#[test]
fn test_validate_issue_id_empty() {
let result = validate_issue_id("");
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot be empty"));
}
#[test]
fn test_validate_issue_id_no_hyphen() {
let result = validate_issue_id("projabc");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Expected format"));
}
#[test]
fn test_validate_issue_id_empty_suffix() {
let result = validate_issue_id("proj-");
assert!(result.is_err());
assert!(result.unwrap_err().contains("suffix cannot be empty"));
}
#[test]
fn test_validate_issue_id_prefix_too_short() {
let result = validate_issue_id("a-123");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_lowercase()
.contains("at least 2 characters"));
}
#[test]
fn test_validate_issue_id_invalid_chars() {
assert!(validate_issue_id("proj-abc_123").is_err()); assert!(validate_issue_id("proj_test-abc").is_err()); }
#[test]
fn test_validate_issue_id_multiple_hyphens() {
assert!(validate_issue_id("proj-abc-123").is_ok());
assert!(validate_issue_id("rivets-feature-xyz").is_ok());
assert!(validate_issue_id("test-a-b-c-d").is_ok());
assert_eq!(validate_issue_id("proj-abc-123").unwrap(), "proj-abc-123");
}
#[test]
fn test_validate_issue_id_prefix_exactly_20_chars() {
let prefix_20 = "a".repeat(20);
let issue_id = format!("{}-xyz", prefix_20);
assert!(validate_issue_id(&issue_id).is_ok());
}
#[test]
fn test_validate_issue_id_prefix_21_chars() {
let prefix_21 = "a".repeat(21);
let issue_id = format!("{}-xyz", prefix_21);
let result = validate_issue_id(&issue_id);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_lowercase()
.contains("cannot exceed 20"));
}
#[test]
fn test_validate_issue_id_leading_hyphen_suffix() {
let result = validate_issue_id("proj--abc");
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot start with a hyphen"));
}
#[test]
fn test_validate_issue_id_trailing_hyphen_suffix() {
let result = validate_issue_id("proj-abc-");
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot end with a hyphen"));
}
#[test]
fn test_validate_issue_id_consecutive_hyphens() {
let result = validate_issue_id("proj-a--b");
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("cannot contain consecutive hyphens"));
}
#[test]
fn test_validate_title_valid() {
assert!(validate_title("Short title").is_ok());
assert!(validate_title("A".repeat(200).as_str()).is_ok()); }
#[test]
fn test_validate_title_empty() {
let result = validate_title("");
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot be empty"));
}
#[test]
fn test_validate_title_too_long() {
let long_title = "A".repeat(201);
let result = validate_title(&long_title);
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot exceed 200"));
}
#[test]
fn test_validate_title_exactly_max_length() {
let max_title = "A".repeat(200);
assert!(validate_title(&max_title).is_ok());
assert_eq!(validate_title(&max_title).unwrap().len(), 200);
}
#[test]
fn test_validate_title_trims_whitespace() {
assert_eq!(validate_title(" Test Title ").unwrap(), "Test Title");
}
#[test]
fn test_validate_title_whitespace_only() {
let result = validate_title(" ");
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot be empty"));
}
#[test]
fn test_validate_title_with_newline() {
let result = validate_title("Title with\nnewline");
assert!(result.is_err());
assert!(result.unwrap_err().contains("newline"));
}
#[test]
fn test_validate_title_with_carriage_return() {
let result = validate_title("Title with\rcarriage return");
assert!(result.is_err());
assert!(result.unwrap_err().contains("newline"));
}
#[test]
fn test_validate_title_with_control_character() {
let result = validate_title("Title with\x00control");
assert!(result.is_err());
assert!(result.unwrap_err().contains("control character"));
}
#[test]
fn test_validate_title_with_tab_allowed() {
let result = validate_title("Title with\ttab");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "Title with\ttab");
}
#[test]
fn test_validate_title_with_delete_character() {
let result = validate_title("Title with\x7Fdelete");
assert!(result.is_err());
assert!(result.unwrap_err().contains("control character"));
}
#[test]
fn test_validate_description_with_newline_allowed() {
let result = validate_description("Multi-line\ndescription");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "Multi-line\ndescription");
}
#[test]
fn test_validate_description_with_control_character() {
let result = validate_description("Description with\x00control");
assert!(result.is_err());
assert!(result.unwrap_err().contains("control character"));
}
#[test]
fn test_validate_description_with_tab_and_newline() {
let result = validate_description("Line1\n\tIndented line");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "Line1\n\tIndented line");
}
#[test]
fn test_validate_description_empty() {
let result = validate_description("");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "");
}
#[test]
fn test_validate_label_valid() {
assert!(validate_label("bug").is_ok());
assert!(validate_label("feature").is_ok());
assert!(validate_label("high-priority").is_ok());
assert!(validate_label("needs_review").is_ok());
assert!(validate_label("v2").is_ok());
assert!(validate_label("p0").is_ok());
assert!(validate_label("front-end").is_ok());
assert!(validate_label("back_end").is_ok());
}
#[test]
fn test_validate_label_empty() {
let result = validate_label("");
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot be empty"));
}
#[test]
fn test_validate_label_whitespace_only() {
let result = validate_label(" ");
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot be empty"));
}
#[test]
fn test_validate_label_trims_whitespace() {
assert_eq!(validate_label(" bug ").unwrap(), "bug");
}
#[test]
fn test_validate_label_too_long() {
let long_label = "a".repeat(51);
let result = validate_label(&long_label);
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot exceed 50"));
}
#[test]
fn test_validate_label_exactly_max_length() {
let max_label = "a".repeat(50);
assert!(validate_label(&max_label).is_ok());
assert_eq!(validate_label(&max_label).unwrap().len(), 50);
}
#[test]
fn test_validate_label_uppercase_rejected() {
let result = validate_label("Bug");
assert!(result.is_err());
assert!(result.unwrap_err().contains("lowercase"));
}
#[test]
fn test_validate_label_space_rejected() {
let result = validate_label("high priority");
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("lowercase letters, numbers, hyphens"));
}
#[test]
fn test_validate_label_special_chars_rejected() {
assert!(validate_label("bug!").is_err());
assert!(validate_label("feature@v2").is_err());
assert!(validate_label("needs.review").is_err());
assert!(validate_label("test/label").is_err());
}
#[test]
fn test_validate_label_must_start_with_alphanumeric() {
let result = validate_label("-bug");
assert!(result.is_err());
assert!(result.unwrap_err().contains("must start with"));
let result = validate_label("_bug");
assert!(result.is_err());
assert!(result.unwrap_err().contains("must start with"));
}
#[test]
fn test_validate_label_must_end_with_alphanumeric() {
let result = validate_label("bug-");
assert!(result.is_err());
assert!(result.unwrap_err().contains("must end with"));
let result = validate_label("bug_");
assert!(result.is_err());
assert!(result.unwrap_err().contains("must end with"));
}
#[rstest]
#[case::two_hyphens("high--priority")]
#[case::three_hyphens("high---priority")]
#[case::four_hyphens("high----priority")]
fn test_validate_label_no_consecutive_hyphens(#[case] input: &str) {
let result = validate_label(input);
assert!(result.is_err());
assert!(result.unwrap_err().contains("consecutive hyphens"));
}
#[rstest]
#[case::two_underscores("needs__review")]
#[case::three_underscores("needs___review")]
#[case::four_underscores("needs____review")]
fn test_validate_label_no_consecutive_underscores(#[case] input: &str) {
let result = validate_label(input);
assert!(result.is_err());
assert!(result.unwrap_err().contains("consecutive underscores"));
}
#[test]
fn test_validate_label_mixed_separators_allowed() {
assert!(validate_label("high-priority_v2").is_ok());
assert!(validate_label("needs_review-urgent").is_ok());
}
}