1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum OrderDir {
11 Asc,
12 Desc,
13}
14
15impl OrderDir {
16 fn as_str(self) -> &'static str {
17 match self {
18 OrderDir::Asc => "ASC",
19 OrderDir::Desc => "DESC",
20 }
21 }
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(tag = "type", rename_all = "snake_case")]
26pub enum AssigneeFilter {
27 CurrentUser,
28 Empty,
29 AccountId { account_id: String },
30 Email { email: String },
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(tag = "kind", rename_all = "snake_case")]
35pub enum DateExpr {
36 Date { date: String },
38 Relative { window: String },
40}
41
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43#[serde(default)]
44pub struct JqlParams {
45 pub project: Option<String>,
46 pub status: Vec<String>,
47 pub assignee: Vec<AssigneeFilter>,
48 pub priority: Vec<String>,
49 pub labels: Vec<String>,
50 pub components: Vec<String>,
51 pub fix_versions: Vec<String>,
52 pub text: Option<String>,
53 pub created_after: Option<DateExpr>,
54 pub updated_after: Option<DateExpr>,
55 pub extra_clauses: Vec<String>,
56 pub order_by: Vec<(String, OrderDir)>,
57}
58
59#[derive(Debug, thiserror::Error)]
60pub enum JqlBuildError {
61 #[error("value contains control characters: {0:?}")]
62 ControlChar(String),
63 #[error("relative window must match `-?[0-9]+[dwmy]`, got {0:?}")]
64 BadRelativeWindow(String),
65 #[error("date must be YYYY-MM-DD, got {0:?}")]
66 BadDate(String),
67 #[error("field name must match `[A-Za-z0-9_.]+`, got {0:?}")]
68 BadFieldName(String),
69}
70
71pub fn escape_jql_literal(value: &str) -> Result<String, JqlBuildError> {
77 if value.chars().any(|c| c.is_control() && c != '\t') {
78 return Err(JqlBuildError::ControlChar(value.to_string()));
79 }
80 Ok(value.replace('\\', "\\\\").replace('"', "\\\""))
81}
82
83fn validate_field_name(name: &str) -> Result<&str, JqlBuildError> {
85 let trimmed = name.trim();
86 if trimmed.is_empty()
87 || !trimmed
88 .chars()
89 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
90 {
91 return Err(JqlBuildError::BadFieldName(name.to_string()));
92 }
93 Ok(trimmed)
94}
95
96fn validate_relative_window(s: &str) -> Result<&str, JqlBuildError> {
97 let trimmed = s.trim();
98 let mut chars = trimmed.chars().peekable();
99 if let Some('-') = chars.peek() {
100 chars.next();
101 }
102 let mut digits = String::new();
103 while let Some(&c) = chars.peek() {
104 if c.is_ascii_digit() {
105 digits.push(c);
106 chars.next();
107 } else {
108 break;
109 }
110 }
111 let unit: String = chars.collect();
112 if digits.is_empty() || !matches!(unit.as_str(), "d" | "w" | "m" | "M" | "y") {
113 return Err(JqlBuildError::BadRelativeWindow(s.to_string()));
114 }
115 Ok(trimmed)
116}
117
118fn validate_date(s: &str) -> Result<&str, JqlBuildError> {
119 let trimmed = s.trim();
120 let parts: Vec<&str> = trimmed.split('-').collect();
121 let lens_ok =
122 parts.len() == 3 && parts[0].len() == 4 && parts[1].len() == 2 && parts[2].len() == 2;
123 let digits_ok = parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit()));
124 if !lens_ok || !digits_ok {
125 return Err(JqlBuildError::BadDate(s.to_string()));
126 }
127 Ok(trimmed)
128}
129
130fn or_clause(field: &str, values: &[String]) -> Result<Option<String>, JqlBuildError> {
131 if values.is_empty() {
132 return Ok(None);
133 }
134 let mut quoted = Vec::with_capacity(values.len());
135 for v in values {
136 quoted.push(format!("\"{}\"", escape_jql_literal(v)?));
137 }
138 Ok(Some(format!("{} in ({})", field, quoted.join(", "))))
139}
140
141fn assignee_clause(filters: &[AssigneeFilter]) -> Result<Option<String>, JqlBuildError> {
142 if filters.is_empty() {
143 return Ok(None);
144 }
145 let mut parts = Vec::with_capacity(filters.len());
146 for f in filters {
147 match f {
148 AssigneeFilter::CurrentUser => parts.push("assignee = currentUser()".to_string()),
149 AssigneeFilter::Empty => parts.push("assignee is EMPTY".to_string()),
150 AssigneeFilter::AccountId { account_id } => parts.push(format!(
151 "assignee = \"{}\"",
152 escape_jql_literal(account_id)?
153 )),
154 AssigneeFilter::Email { email } => {
155 parts.push(format!("assignee = \"{}\"", escape_jql_literal(email)?))
156 }
157 }
158 }
159 Ok(Some(if parts.len() == 1 {
160 parts.remove(0)
161 } else {
162 format!("({})", parts.join(" OR "))
163 }))
164}
165
166fn date_clause(field: &str, expr: &DateExpr) -> Result<String, JqlBuildError> {
167 let rhs = match expr {
168 DateExpr::Date { date } => format!("\"{}\"", validate_date(date)?),
169 DateExpr::Relative { window } => format!("\"{}\"", validate_relative_window(window)?),
170 };
171 Ok(format!("{} >= {}", field, rhs))
172}
173
174pub fn compose_jql(params: &JqlParams) -> Result<String, JqlBuildError> {
178 let mut clauses: Vec<String> = Vec::new();
179
180 if let Some(project) = ¶ms.project {
181 clauses.push(format!(
182 "project = \"{}\"",
183 escape_jql_literal(project.trim())?
184 ));
185 }
186 if let Some(c) = or_clause("status", ¶ms.status)? {
187 clauses.push(c);
188 }
189 if let Some(c) = assignee_clause(¶ms.assignee)? {
190 clauses.push(c);
191 }
192 if let Some(c) = or_clause("priority", ¶ms.priority)? {
193 clauses.push(c);
194 }
195 if let Some(c) = or_clause("labels", ¶ms.labels)? {
196 clauses.push(c);
197 }
198 if let Some(c) = or_clause("component", ¶ms.components)? {
199 clauses.push(c);
200 }
201 if let Some(c) = or_clause("fixVersion", ¶ms.fix_versions)? {
202 clauses.push(c);
203 }
204 if let Some(text) = ¶ms.text {
205 clauses.push(format!("text ~ \"{}\"", escape_jql_literal(text)?));
206 }
207 if let Some(expr) = ¶ms.created_after {
208 clauses.push(date_clause("created", expr)?);
209 }
210 if let Some(expr) = ¶ms.updated_after {
211 clauses.push(date_clause("updated", expr)?);
212 }
213 for extra in ¶ms.extra_clauses {
214 let trimmed = extra.trim();
215 if trimmed.is_empty() {
216 continue;
217 }
218 if trimmed.chars().any(|c| c.is_control() && c != '\t') {
219 return Err(JqlBuildError::ControlChar(extra.clone()));
220 }
221 clauses.push(format!("({})", trimmed));
222 }
223
224 let mut jql = clauses.join(" AND ");
225
226 if !params.order_by.is_empty() {
227 let mut order_parts = Vec::with_capacity(params.order_by.len());
228 for (field, dir) in ¶ms.order_by {
229 let name = validate_field_name(field)?;
230 order_parts.push(format!("{} {}", name, dir.as_str()));
231 }
232 if !jql.is_empty() {
233 jql.push(' ');
234 }
235 jql.push_str("ORDER BY ");
236 jql.push_str(&order_parts.join(", "));
237 }
238
239 Ok(jql)
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn escape_basic() {
248 assert_eq!(escape_jql_literal("plain").unwrap(), "plain");
249 assert_eq!(escape_jql_literal("a\"b").unwrap(), "a\\\"b");
250 assert_eq!(escape_jql_literal("a\\b").unwrap(), "a\\\\b");
251 assert_eq!(escape_jql_literal("O'Reilly").unwrap(), "O'Reilly");
252 }
253
254 #[test]
255 fn escape_rejects_control_chars() {
256 assert!(escape_jql_literal("line1\nline2").is_err());
257 assert!(escape_jql_literal("bell\x07").is_err());
258 assert!(escape_jql_literal("tab\there").is_ok());
259 }
260
261 #[test]
262 fn empty_params_returns_empty() {
263 let jql = compose_jql(&JqlParams::default()).unwrap();
264 assert_eq!(jql, "");
265 }
266
267 #[test]
268 fn project_and_status() {
269 let p = JqlParams {
270 project: Some("ABC".into()),
271 status: vec!["In Progress".into(), "Done".into()],
272 ..Default::default()
273 };
274 let jql = compose_jql(&p).unwrap();
275 assert_eq!(
276 jql,
277 r#"project = "ABC" AND status in ("In Progress", "Done")"#
278 );
279 }
280
281 #[test]
282 fn assignee_mixed() {
283 let p = JqlParams {
284 assignee: vec![
285 AssigneeFilter::CurrentUser,
286 AssigneeFilter::Email {
287 email: "u@x.com".into(),
288 },
289 ],
290 ..Default::default()
291 };
292 let jql = compose_jql(&p).unwrap();
293 assert_eq!(jql, r#"(assignee = currentUser() OR assignee = "u@x.com")"#);
294 }
295
296 #[test]
297 fn assignee_empty_clause() {
298 let p = JqlParams {
299 assignee: vec![AssigneeFilter::Empty],
300 ..Default::default()
301 };
302 assert_eq!(compose_jql(&p).unwrap(), "assignee is EMPTY");
303 }
304
305 #[test]
306 fn text_search_escapes_quotes() {
307 let p = JqlParams {
308 text: Some(r#"He said "hi""#.into()),
309 ..Default::default()
310 };
311 let jql = compose_jql(&p).unwrap();
312 assert_eq!(jql, r#"text ~ "He said \"hi\"""#);
313 }
314
315 #[test]
316 fn dates_absolute_and_relative() {
317 let p = JqlParams {
318 created_after: Some(DateExpr::Date {
319 date: "2026-01-01".into(),
320 }),
321 updated_after: Some(DateExpr::Relative {
322 window: "-7d".into(),
323 }),
324 ..Default::default()
325 };
326 let jql = compose_jql(&p).unwrap();
327 assert_eq!(jql, r#"created >= "2026-01-01" AND updated >= "-7d""#);
328 }
329
330 #[test]
331 fn bad_date_rejected() {
332 let p = JqlParams {
333 created_after: Some(DateExpr::Date {
334 date: "2026/01/01".into(),
335 }),
336 ..Default::default()
337 };
338 assert!(matches!(compose_jql(&p), Err(JqlBuildError::BadDate(_))));
339 }
340
341 #[test]
342 fn bad_relative_window_rejected() {
343 for bad in ["7", "-7x", "abc", ""] {
344 let p = JqlParams {
345 updated_after: Some(DateExpr::Relative { window: bad.into() }),
346 ..Default::default()
347 };
348 assert!(
349 matches!(compose_jql(&p), Err(JqlBuildError::BadRelativeWindow(_))),
350 "should reject {bad:?}"
351 );
352 }
353 }
354
355 #[test]
356 fn order_by_validated() {
357 let p = JqlParams {
358 project: Some("ABC".into()),
359 order_by: vec![
360 ("priority".into(), OrderDir::Desc),
361 ("created".into(), OrderDir::Asc),
362 ],
363 ..Default::default()
364 };
365 let jql = compose_jql(&p).unwrap();
366 assert_eq!(
367 jql,
368 r#"project = "ABC" ORDER BY priority DESC, created ASC"#
369 );
370 }
371
372 #[test]
373 fn order_by_rejects_bad_field() {
374 let p = JqlParams {
375 order_by: vec![("priority; DROP".into(), OrderDir::Asc)],
376 ..Default::default()
377 };
378 assert!(matches!(
379 compose_jql(&p),
380 Err(JqlBuildError::BadFieldName(_))
381 ));
382 }
383
384 #[test]
385 fn extra_clauses_wrapped() {
386 let p = JqlParams {
387 project: Some("ABC".into()),
388 extra_clauses: vec!["sprint in openSprints()".into()],
389 ..Default::default()
390 };
391 let jql = compose_jql(&p).unwrap();
392 assert_eq!(jql, r#"project = "ABC" AND (sprint in openSprints())"#);
393 }
394
395 #[test]
396 fn extra_clauses_reject_control() {
397 let p = JqlParams {
398 extra_clauses: vec!["foo\nbar".into()],
399 ..Default::default()
400 };
401 assert!(matches!(
402 compose_jql(&p),
403 Err(JqlBuildError::ControlChar(_))
404 ));
405 }
406
407 #[test]
408 fn labels_components_versions() {
409 let p = JqlParams {
410 labels: vec!["needs-review".into()],
411 components: vec!["api".into(), "ui".into()],
412 fix_versions: vec!["1.2.0".into()],
413 ..Default::default()
414 };
415 let jql = compose_jql(&p).unwrap();
416 assert_eq!(
417 jql,
418 r#"labels in ("needs-review") AND component in ("api", "ui") AND fixVersion in ("1.2.0")"#
419 );
420 }
421}