Skip to main content

jira_core/
jql.rs

1//! Structured JQL builder.
2//!
3//! Compose safe JQL queries from typed parameters. Values are escaped
4//! to avoid injection through user-supplied identifiers, labels, or text.
5
6use 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    /// Absolute date `YYYY-MM-DD`.
37    Date { date: String },
38    /// Relative window like `-7d`, `-2w`, `-1M`.
39    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
71/// Escape a value for use inside a double-quoted JQL literal.
72///
73/// Replaces `\` → `\\` and `"` → `\"`. Rejects control characters
74/// (any char with `is_control()` other than tab) to avoid breaking
75/// downstream parsers or smuggling newlines.
76pub 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
83/// Validate a JQL field name (used for ORDER BY and custom fields).
84fn 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
174/// Compose a JQL query from structured parameters.
175///
176/// Returns `Ok("")` if every input is empty (caller may treat as "no filter").
177pub fn compose_jql(params: &JqlParams) -> Result<String, JqlBuildError> {
178    let mut clauses: Vec<String> = Vec::new();
179
180    if let Some(project) = &params.project {
181        clauses.push(format!(
182            "project = \"{}\"",
183            escape_jql_literal(project.trim())?
184        ));
185    }
186    if let Some(c) = or_clause("status", &params.status)? {
187        clauses.push(c);
188    }
189    if let Some(c) = assignee_clause(&params.assignee)? {
190        clauses.push(c);
191    }
192    if let Some(c) = or_clause("priority", &params.priority)? {
193        clauses.push(c);
194    }
195    if let Some(c) = or_clause("labels", &params.labels)? {
196        clauses.push(c);
197    }
198    if let Some(c) = or_clause("component", &params.components)? {
199        clauses.push(c);
200    }
201    if let Some(c) = or_clause("fixVersion", &params.fix_versions)? {
202        clauses.push(c);
203    }
204    if let Some(text) = &params.text {
205        clauses.push(format!("text ~ \"{}\"", escape_jql_literal(text)?));
206    }
207    if let Some(expr) = &params.created_after {
208        clauses.push(date_clause("created", expr)?);
209    }
210    if let Some(expr) = &params.updated_after {
211        clauses.push(date_clause("updated", expr)?);
212    }
213    for extra in &params.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 &params.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}