pub struct JqlBuilder {
conditions: Vec<String>,
}
impl JqlBuilder {
pub fn new() -> Self {
Self {
conditions: Vec::new(),
}
}
pub fn eq(mut self, field: &str, value: &str) -> Self {
let normalized = Self::normalize_value(field, value);
self.conditions.push(format!("{} = {}", field, normalized));
self
}
pub fn in_list(mut self, field: &str, values: &[String]) -> Self {
if values.is_empty() {
return self;
}
let escaped_values: Vec<String> =
values.iter().map(|v| Self::escape_and_quote(v)).collect();
self.conditions
.push(format!("{} IN ({})", field, escaped_values.join(", ")));
self
}
pub fn contains(mut self, field: &str, value: &str) -> Self {
let escaped = Self::escape_and_quote(value);
self.conditions.push(format!("{} ~ {}", field, escaped));
self
}
pub fn finish(self) -> String {
if self.conditions.is_empty() {
return String::new();
}
self.conditions.join(" AND ")
}
fn escape_and_quote(value: &str) -> String {
let escaped = value
.replace('\\', "\\\\") .replace('"', "\\\""); format!("\"{}\"", escaped)
}
fn normalize_value(field: &str, value: &str) -> String {
match (field, value) {
("assignee" | "reporter" | "creator" | "watcher", "@me") => "currentUser()".to_string(),
(_, "unassigned" | "none" | "empty") => "EMPTY".to_string(),
_ => Self::escape_and_quote(value),
}
}
}
impl Default for JqlBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_builder() {
let query = JqlBuilder::new().finish();
assert_eq!(query, "");
}
#[test]
fn test_single_eq() {
let query = JqlBuilder::new().eq("project", "PROJ").finish();
assert_eq!(query, "project = \"PROJ\"");
}
#[test]
fn test_assignee_me_shorthand() {
let query = JqlBuilder::new().eq("assignee", "@me").finish();
assert_eq!(query, "assignee = currentUser()");
}
#[test]
fn test_reporter_me_shorthand() {
let query = JqlBuilder::new().eq("reporter", "@me").finish();
assert_eq!(query, "reporter = currentUser()");
}
#[test]
fn test_unassigned_shorthand() {
let query = JqlBuilder::new().eq("assignee", "unassigned").finish();
assert_eq!(query, "assignee = EMPTY");
}
#[test]
fn test_multiple_conditions() {
let query = JqlBuilder::new()
.eq("assignee", "@me")
.eq("project", "TEST")
.finish();
assert_eq!(query, "assignee = currentUser() AND project = \"TEST\"");
}
#[test]
fn test_in_list_single() {
let query = JqlBuilder::new()
.in_list("status", &[String::from("Open")])
.finish();
assert_eq!(query, "status IN (\"Open\")");
}
#[test]
fn test_in_list_multiple() {
let query = JqlBuilder::new()
.in_list(
"status",
&[String::from("Open"), String::from("In Progress")],
)
.finish();
assert_eq!(query, "status IN (\"Open\", \"In Progress\")");
}
#[test]
fn test_in_list_empty() {
let query = JqlBuilder::new().in_list("status", &[]).finish();
assert_eq!(query, "");
}
#[test]
fn test_contains() {
let query = JqlBuilder::new().contains("summary", "bug fix").finish();
assert_eq!(query, "summary ~ \"bug fix\"");
}
#[test]
fn test_quote_escape() {
let query = JqlBuilder::new()
.eq("summary", "Fix \"bug\" issue")
.finish();
assert_eq!(query, "summary = \"Fix \\\"bug\\\" issue\"");
}
#[test]
fn test_backslash_escape() {
let query = JqlBuilder::new().eq("summary", "Path\\to\\file").finish();
assert_eq!(query, "summary = \"Path\\\\to\\\\file\"");
}
#[test]
fn test_complex_query() {
let query = JqlBuilder::new()
.eq("assignee", "@me")
.in_list(
"status",
&[String::from("Open"), String::from("In Progress")],
)
.eq("priority", "High")
.in_list("label", &[String::from("bug"), String::from("backend")])
.finish();
assert_eq!(
query,
"assignee = currentUser() AND status IN (\"Open\", \"In Progress\") AND priority = \"High\" AND label IN (\"bug\", \"backend\")"
);
}
#[cfg(test)]
mod property_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn escape_never_panics(input in "\\PC*") {
let _ = JqlBuilder::escape_and_quote(&input);
}
#[test]
fn escaped_strings_are_quoted(input in "\\PC*") {
let escaped = JqlBuilder::escape_and_quote(&input);
prop_assert!(escaped.starts_with('"'));
prop_assert!(escaped.ends_with('"'));
}
#[test]
fn escaping_increases_or_maintains_length(input in "\\PC*") {
let escaped = JqlBuilder::escape_and_quote(&input);
prop_assert!(escaped.len() >= input.len() + 2);
}
#[test]
fn no_unescaped_quotes_in_output(input in "\\PC*") {
let escaped = JqlBuilder::escape_and_quote(&input);
let inner = &escaped[1..escaped.len()-1];
for (i, c) in inner.chars().enumerate() {
if c == '"' {
if i > 0 {
prop_assert_eq!(inner.chars().nth(i-1), Some('\\'));
}
}
}
}
#[test]
fn builder_with_condition_produces_output(
field in "[a-z]+",
value in "\\PC*"
) {
let query = JqlBuilder::new().eq(&field, &value).finish();
prop_assert!(!query.is_empty());
prop_assert!(query.contains(&field));
}
#[test]
fn multiple_conditions_use_and(
field1 in "[a-z]+",
value1 in "\\PC{0,20}",
field2 in "[a-z]+",
value2 in "\\PC{0,20}"
) {
let query = JqlBuilder::new()
.eq(&field1, &value1)
.eq(&field2, &value2)
.finish();
if field1 != field2 || value1 != value2 {
prop_assert!(query.contains(" AND "));
}
}
#[test]
fn in_list_never_panics(
field in "[a-z]+",
values in prop::collection::vec("\\PC{0,20}", 0..10)
) {
let strings: Vec<String> = values.iter().map(|s| s.to_string()).collect();
let query = JqlBuilder::new().in_list(&field, &strings).finish();
if !strings.is_empty() {
prop_assert!(query.contains(&field));
prop_assert!(query.contains(" IN ("));
}
}
}
}
}