work_tuimer/integrations/
mod.rs

1use crate::config::Config;
2use anyhow::Result;
3use regex::Regex;
4
5/// Extract ticket ID from task name using regex pattern: "PROJ-123 - Task name" -> "PROJ-123"
6pub fn extract_ticket_from_name(name: &str) -> Option<String> {
7    // Match common ticket patterns: WORD-NUMBER (e.g., PROJ-123, WL-1, LIN-456)
8    let re = Regex::new(r"\b([A-Z]{2,10}-\d+)\b").ok()?;
9
10    re.captures(name)
11        .and_then(|caps| caps.get(1))
12        .map(|m| m.as_str().to_string())
13}
14
15/// Detect which tracker a ticket belongs to based on config patterns
16/// Returns the tracker name if a match is found
17pub fn detect_tracker(ticket: &str, config: &Config) -> Option<String> {
18    // Try each enabled tracker's patterns
19    for (name, tracker_config) in &config.integrations.trackers {
20        if tracker_config.enabled && matches_patterns(ticket, &tracker_config.ticket_patterns) {
21            return Some(name.clone());
22        }
23    }
24
25    // Fallback to default tracker if configured
26    config.integrations.default_tracker.clone()
27}
28
29/// Check if ticket matches any of the provided patterns
30fn matches_patterns(ticket: &str, patterns: &[String]) -> bool {
31    patterns.iter().any(|pattern| {
32        Regex::new(pattern)
33            .ok()
34            .map(|re| re.is_match(ticket))
35            .unwrap_or(false)
36    })
37}
38
39/// Build a URL for the given ticket and tracker name
40pub fn build_url(
41    ticket: &str,
42    tracker_name: &str,
43    config: &Config,
44    for_worklog: bool,
45) -> Result<String> {
46    let tracker_config = config
47        .integrations
48        .trackers
49        .get(tracker_name)
50        .ok_or_else(|| anyhow::anyhow!("Tracker '{}' not found in config", tracker_name))?;
51
52    if !tracker_config.enabled {
53        anyhow::bail!("Tracker '{}' is not enabled in config", tracker_name);
54    }
55
56    let template = if for_worklog && !tracker_config.worklog_url.is_empty() {
57        &tracker_config.worklog_url
58    } else {
59        &tracker_config.browse_url
60    };
61
62    let url = template
63        .replace("{base_url}", &tracker_config.base_url)
64        .replace("{ticket}", ticket);
65
66    Ok(url)
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn test_extract_ticket_simple() {
75        let name = "PROJ-123 Fix login bug";
76        let ticket = extract_ticket_from_name(name);
77        assert_eq!(ticket, Some("PROJ-123".to_string()));
78    }
79
80    #[test]
81    fn test_extract_ticket_wl_format() {
82        let name = "WL-1 Morning standup";
83        let ticket = extract_ticket_from_name(name);
84        assert_eq!(ticket, Some("WL-1".to_string()));
85    }
86
87    #[test]
88    fn test_extract_ticket_lin_format() {
89        let name = "LIN-456 Code review";
90        let ticket = extract_ticket_from_name(name);
91        assert_eq!(ticket, Some("LIN-456".to_string()));
92    }
93
94    #[test]
95    fn test_extract_ticket_bracketed() {
96        let name = "[ABC-789] Task name";
97        let ticket = extract_ticket_from_name(name);
98        assert_eq!(ticket, Some("ABC-789".to_string()));
99    }
100
101    #[test]
102    fn test_extract_ticket_in_middle() {
103        let name = "Work on PROJ-456 - code cleanup";
104        let ticket = extract_ticket_from_name(name);
105        assert_eq!(ticket, Some("PROJ-456".to_string()));
106    }
107
108    #[test]
109    fn test_extract_ticket_no_ticket() {
110        let name = "Just a regular task";
111        let ticket = extract_ticket_from_name(name);
112        assert_eq!(ticket, None);
113    }
114
115    #[test]
116    fn test_extract_ticket_invalid_format() {
117        let name = "task-123 invalid";
118        let ticket = extract_ticket_from_name(name);
119        assert_eq!(ticket, None); // lowercase doesn't match
120    }
121
122    #[test]
123    fn test_detect_tracker_by_pattern() {
124        let toml_str = r#"
125[integrations]
126default_tracker = "my-jira"
127
128[integrations.trackers.my-jira]
129enabled = true
130base_url = "https://test.atlassian.net"
131ticket_patterns = ["^PROJ-\\d+$", "^WL-\\d+$"]
132browse_url = "{base_url}/browse/{ticket}"
133        "#;
134        let config: Config = toml::from_str(toml_str).unwrap();
135
136        let tracker = detect_tracker("PROJ-123", &config);
137        assert_eq!(tracker, Some("my-jira".to_string()));
138
139        let tracker = detect_tracker("WL-1", &config);
140        assert_eq!(tracker, Some("my-jira".to_string()));
141    }
142
143    #[test]
144    fn test_detect_tracker_default_fallback() {
145        let toml_str = r#"
146[integrations]
147default_tracker = "my-jira"
148
149[integrations.trackers.my-jira]
150enabled = true
151base_url = "https://test.atlassian.net"
152ticket_patterns = ["^PROJ-\\d+$"]
153browse_url = "{base_url}/browse/{ticket}"
154        "#;
155        let config: Config = toml::from_str(toml_str).unwrap();
156
157        // UNKNOWN-999 doesn't match pattern, falls back to default
158        let tracker = detect_tracker("UNKNOWN-999", &config);
159        assert_eq!(tracker, Some("my-jira".to_string()));
160    }
161
162    #[test]
163    fn test_detect_tracker_multiple_trackers() {
164        let toml_str = r#"
165[integrations]
166default_tracker = "my-jira"
167
168[integrations.trackers.my-jira]
169enabled = true
170base_url = "https://test.atlassian.net"
171ticket_patterns = ["^PROJ-\\d+$"]
172browse_url = "{base_url}/browse/{ticket}"
173
174[integrations.trackers.github]
175enabled = true
176base_url = "https://github.com/user/repo"
177ticket_patterns = ["^#\\d+$"]
178browse_url = "{base_url}/issues/{ticket}"
179        "#;
180        let config: Config = toml::from_str(toml_str).unwrap();
181
182        let tracker = detect_tracker("PROJ-123", &config);
183        assert_eq!(tracker, Some("my-jira".to_string()));
184
185        let tracker = detect_tracker("#456", &config);
186        assert_eq!(tracker, Some("github".to_string()));
187    }
188
189    #[test]
190    fn test_detect_tracker_overlapping_patterns_first_wins() {
191        // Test that when multiple trackers match, the first one in iteration order wins
192        let toml_str = r#"
193[integrations]
194default_tracker = "fallback"
195
196[integrations.trackers.jira]
197enabled = true
198base_url = "https://jira.example.com"
199ticket_patterns = ["^[A-Z]+-\\d+$"]
200browse_url = "{base_url}/browse/{ticket}"
201
202[integrations.trackers.linear]
203enabled = true
204base_url = "https://linear.app/team"
205ticket_patterns = ["^[A-Z]+-\\d+$"]
206browse_url = "{base_url}/issue/{ticket}"
207        "#;
208        let config: Config = toml::from_str(toml_str).unwrap();
209
210        // PROJ-123 matches both patterns - should use whichever tracker appears first
211        let tracker = detect_tracker("PROJ-123", &config);
212        assert!(tracker.is_some());
213
214        // The result should be deterministic (either "jira" or "linear")
215        // Note: HashMap iteration order is not guaranteed in Rust, but it should be consistent
216        let tracker_name = tracker.unwrap();
217        assert!(tracker_name == "jira" || tracker_name == "linear");
218
219        // Verify the same ticket always resolves to the same tracker
220        let tracker2 = detect_tracker("PROJ-123", &config);
221        assert_eq!(tracker2, Some(tracker_name));
222    }
223
224    #[test]
225    fn test_detect_tracker_no_match_no_default() {
226        // Test that when no patterns match and no default is set, returns None
227        let toml_str = r#"
228[integrations]
229
230[integrations.trackers.jira]
231enabled = true
232base_url = "https://jira.example.com"
233ticket_patterns = ["^PROJ-\\d+$"]
234browse_url = "{base_url}/browse/{ticket}"
235        "#;
236        let config: Config = toml::from_str(toml_str).unwrap();
237
238        // UNKNOWN-999 doesn't match and there's no default_tracker
239        let tracker = detect_tracker("UNKNOWN-999", &config);
240        assert_eq!(tracker, None);
241    }
242
243    #[test]
244    fn test_build_url_browse() {
245        let toml_str = r#"
246[integrations]
247default_tracker = "my-jira"
248
249[integrations.trackers.my-jira]
250enabled = true
251base_url = "https://test.atlassian.net"
252ticket_patterns = ["^[A-Z]+-\\d+$"]
253browse_url = "{base_url}/browse/{ticket}"
254worklog_url = "{base_url}/browse/{ticket}?focusedWorklogId=-1"
255        "#;
256        let config: Config = toml::from_str(toml_str).unwrap();
257        let url = build_url("WL-1", "my-jira", &config, false);
258        assert!(url.is_ok());
259        assert_eq!(url.unwrap(), "https://test.atlassian.net/browse/WL-1");
260    }
261
262    #[test]
263    fn test_build_url_worklog() {
264        let toml_str = r#"
265[integrations]
266default_tracker = "my-jira"
267
268[integrations.trackers.my-jira]
269enabled = true
270base_url = "https://test.atlassian.net"
271ticket_patterns = ["^[A-Z]+-\\d+$"]
272browse_url = "{base_url}/browse/{ticket}"
273worklog_url = "{base_url}/browse/{ticket}?focusedWorklogId=-1"
274        "#;
275        let config: Config = toml::from_str(toml_str).unwrap();
276        let url = build_url("WL-1", "my-jira", &config, true);
277        assert!(url.is_ok());
278        assert_eq!(
279            url.unwrap(),
280            "https://test.atlassian.net/browse/WL-1?focusedWorklogId=-1"
281        );
282    }
283
284    #[test]
285    fn test_build_url_github() {
286        let toml_str = r#"
287[integrations]
288
289[integrations.trackers.github]
290enabled = true
291base_url = "https://github.com/user/repo"
292ticket_patterns = ["^#\\d+$"]
293browse_url = "{base_url}/issues/{ticket}"
294worklog_url = ""
295        "#;
296
297        let config: Config = toml::from_str(toml_str).unwrap();
298        let url = build_url("#456", "github", &config, false);
299        assert!(url.is_ok());
300        assert_eq!(url.unwrap(), "https://github.com/user/repo/issues/#456");
301    }
302
303    #[test]
304    fn test_matches_patterns() {
305        let patterns = vec!["^[A-Z]+-\\d+$".to_string()];
306        assert!(matches_patterns("PROJ-123", &patterns));
307        assert!(matches_patterns("WL-1", &patterns));
308        assert!(!matches_patterns("invalid", &patterns));
309    }
310
311    #[test]
312    fn test_extract_first_ticket_only() {
313        // If there are multiple tickets, extract the first one
314        let name = "PROJ-123 and WL-456 task";
315        let ticket = extract_ticket_from_name(name);
316        assert_eq!(ticket, Some("PROJ-123".to_string()));
317    }
318
319    #[test]
320    fn test_build_url_with_query_params_in_browse_url() {
321        // Issue #42: URLs with query parameters in browse_url template
322        // e.g., Zentao-style URLs: {base_url}?m=my&f=work&mode=bug
323        let toml_str = r#"
324[integrations]
325default_tracker = "zentao"
326
327[integrations.trackers.zentao]
328enabled = true
329base_url = "http://domain/index.php"
330ticket_patterns = ["^BUG-\\d+$"]
331browse_url = "{base_url}?m=my&f=work&mode=bug&type=assignedTo"
332worklog_url = "{base_url}?m=bug&f=view&bugID={ticket}"
333        "#;
334        let config: Config = toml::from_str(toml_str).unwrap();
335
336        // Test browse URL with query params (no ticket placeholder in browse_url)
337        let url = build_url("BUG-123", "zentao", &config, false);
338        assert!(url.is_ok());
339        let url_str = url.unwrap();
340        assert_eq!(
341            url_str,
342            "http://domain/index.php?m=my&f=work&mode=bug&type=assignedTo"
343        );
344        // Verify URL contains all query parameters
345        assert!(url_str.contains("m=my"));
346        assert!(url_str.contains("f=work"));
347        assert!(url_str.contains("mode=bug"));
348        assert!(url_str.contains("type=assignedTo"));
349    }
350
351    #[test]
352    fn test_build_url_with_ticket_in_query_params() {
353        // Issue #42: worklog URLs that put ticket ID in query parameter
354        let toml_str = r#"
355[integrations]
356default_tracker = "zentao"
357
358[integrations.trackers.zentao]
359enabled = true
360base_url = "http://domain/index.php"
361ticket_patterns = ["^BUG-\\d+$"]
362browse_url = "{base_url}?m=my&f=work&mode=bug"
363worklog_url = "{base_url}?m=bug&f=view&bugID={ticket}"
364        "#;
365        let config: Config = toml::from_str(toml_str).unwrap();
366
367        // Test worklog URL with ticket in query string
368        let url = build_url("BUG-456", "zentao", &config, true);
369        assert!(url.is_ok());
370        let url_str = url.unwrap();
371        assert_eq!(
372            url_str,
373            "http://domain/index.php?m=bug&f=view&bugID=BUG-456"
374        );
375        // Verify ticket was substituted correctly
376        assert!(url_str.contains("bugID=BUG-456"));
377        assert!(url_str.contains("m=bug"));
378        assert!(url_str.contains("f=view"));
379    }
380
381    #[test]
382    fn test_build_url_complex_query_string() {
383        // Test URL with many & characters that could break Windows cmd
384        let toml_str = r#"
385[integrations]
386default_tracker = "tracker"
387
388[integrations.trackers.tracker]
389enabled = true
390base_url = "https://tracker.example.com"
391ticket_patterns = ["^ISSUE-\\d+$"]
392browse_url = "{base_url}/view?id={ticket}&action=show&tab=details&expand=true"
393worklog_url = ""
394        "#;
395        let config: Config = toml::from_str(toml_str).unwrap();
396
397        let url = build_url("ISSUE-789", "tracker", &config, false);
398        assert!(url.is_ok());
399        let url_str = url.unwrap();
400        assert_eq!(
401            url_str,
402            "https://tracker.example.com/view?id=ISSUE-789&action=show&tab=details&expand=true"
403        );
404        // Verify all parts are present
405        assert!(url_str.contains("id=ISSUE-789"));
406        assert!(url_str.contains("action=show"));
407        assert!(url_str.contains("tab=details"));
408        assert!(url_str.contains("expand=true"));
409    }
410}