1use chrono::{DateTime, Utc};
9use regex::Regex;
10use std::process::Command;
11
12#[must_use]
28pub fn truncate_with_suffix(text: &str, max_len: usize, suffix: &str) -> String {
29 let char_count = text.chars().count();
30 if char_count <= max_len {
31 text.to_string()
32 } else {
33 let suffix_len = suffix.chars().count();
34 let truncate_at = max_len.saturating_sub(suffix_len);
35 let truncated: String = text.chars().take(truncate_at).collect();
36 format!("{truncated}{suffix}")
37 }
38}
39
40#[must_use]
59pub fn truncate(text: &str, max_len: usize) -> String {
60 truncate_with_suffix(text, max_len, "...")
61}
62
63#[must_use]
78pub fn format_relative_time(dt: &DateTime<Utc>) -> String {
79 let now = Utc::now();
80 let duration = now.signed_duration_since(*dt);
81
82 if duration.num_days() > 30 {
83 let months = duration.num_days() / 30;
84 if months == 1 {
85 "1 month ago".to_string()
86 } else {
87 format!("{months} months ago")
88 }
89 } else if duration.num_days() > 0 {
90 let days = duration.num_days();
91 if days == 1 {
92 "1 day ago".to_string()
93 } else {
94 format!("{days} days ago")
95 }
96 } else if duration.num_hours() > 0 {
97 let hours = duration.num_hours();
98 if hours == 1 {
99 "1 hour ago".to_string()
100 } else {
101 format!("{hours} hours ago")
102 }
103 } else {
104 "just now".to_string()
105 }
106}
107
108#[must_use]
126pub fn parse_and_format_relative_time(timestamp: &str) -> String {
127 match timestamp.parse::<DateTime<Utc>>() {
128 Ok(dt) => format_relative_time(&dt),
129 Err(_) => timestamp.to_string(),
130 }
131}
132
133#[must_use]
160pub fn is_priority_label(label: &str) -> bool {
161 let lower = label.to_lowercase();
162
163 if lower.len() == 2
165 && lower.starts_with('p')
166 && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
167 {
168 return true;
169 }
170
171 if lower.starts_with("priority:") {
173 let suffix = lower.strip_prefix("priority:").unwrap_or("").trim();
174 return matches!(suffix, "high" | "medium" | "low");
175 }
176
177 false
178}
179
180pub fn parse_git_remote_url(url: &str) -> Result<String, String> {
195 if let Some(ssh_part) = url.strip_prefix("git@github.com:") {
197 let repo = ssh_part.strip_suffix(".git").unwrap_or(ssh_part);
198 return Ok(repo.to_string());
199 }
200
201 if let Some(https_part) = url.strip_prefix("https://github.com/") {
203 let repo = https_part.strip_suffix(".git").unwrap_or(https_part);
204 return Ok(repo.to_string());
205 }
206
207 let re = Regex::new(r"(?:git@|https://)[^/]+[:/]([^/]+)/(.+?)(?:\.git)?$")
209 .map_err(|e| format!("Regex error: {e}"))?;
210
211 if let Some(caps) = re.captures(url)
212 && let (Some(owner), Some(repo)) = (caps.get(1), caps.get(2))
213 {
214 return Ok(format!("{}/{}", owner.as_str(), repo.as_str()));
215 }
216
217 Err(format!("Could not parse git remote URL: {url}"))
218}
219
220pub fn infer_repo_from_git() -> Result<String, String> {
242 let output = Command::new("git")
243 .args(["config", "--get", "remote.origin.url"])
244 .output()
245 .map_err(|e| format!("Failed to run git command: {e}"))?;
246
247 if !output.status.success() {
248 return Err("Not in a git repository or no origin remote configured".to_string());
249 }
250
251 let url = String::from_utf8(output.stdout)
252 .map_err(|e| format!("Invalid UTF-8 in git output: {e}"))?
253 .trim()
254 .to_string();
255
256 if url.is_empty() {
257 return Err("No origin remote configured".to_string());
258 }
259
260 parse_git_remote_url(&url)
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use chrono::Duration;
267
268 #[test]
273 fn truncate_short_text_unchanged() {
274 assert_eq!(truncate("Short title", 50), "Short title");
275 }
276
277 #[test]
278 fn truncate_long_text_with_ellipsis() {
279 let long =
280 "This is a very long title that should be truncated because it exceeds the limit";
281 let result = truncate(long, 30);
282 assert_eq!(result.chars().count(), 30);
283 assert!(result.ends_with("..."));
284 }
285
286 #[test]
287 fn truncate_exact_length_unchanged() {
288 let text = "Exactly twenty chars";
289 assert_eq!(truncate(text, 20), text);
290 }
291
292 #[test]
293 fn truncate_utf8_multibyte_safe() {
294 let title = "Fix emoji handling in parser";
296 let result = truncate(title, 20);
297 assert_eq!(result.chars().count(), 20);
298 assert!(result.ends_with("..."));
299 }
300
301 #[test]
306 fn truncate_with_suffix_short_text_unchanged() {
307 let body = "Short body";
308 assert_eq!(
309 truncate_with_suffix(body, 100, "... [truncated]"),
310 "Short body"
311 );
312 }
313
314 #[test]
315 fn truncate_with_suffix_long_text() {
316 let body = "This is a very long body that should be truncated because it exceeds the maximum length";
317 let result = truncate_with_suffix(body, 50, "... [truncated]");
318 assert!(result.ends_with("... [truncated]"));
319 assert!(result.chars().count() <= 50);
320 }
321
322 #[test]
323 fn truncate_with_suffix_exact_length() {
324 let body = "Exactly fifty characters long text here now ok ye";
325 let result = truncate_with_suffix(body, 50, "... [truncated]");
326 assert_eq!(result, body);
328 }
329
330 #[test]
335 fn relative_time_just_now() {
336 let now = Utc::now();
337 assert_eq!(format_relative_time(&now), "just now");
338 }
339
340 #[test]
341 fn relative_time_one_hour() {
342 let one_hour_ago = Utc::now() - Duration::hours(1);
343 assert_eq!(format_relative_time(&one_hour_ago), "1 hour ago");
344 }
345
346 #[test]
347 fn relative_time_multiple_hours() {
348 let five_hours_ago = Utc::now() - Duration::hours(5);
349 assert_eq!(format_relative_time(&five_hours_ago), "5 hours ago");
350 }
351
352 #[test]
353 fn relative_time_one_day() {
354 let one_day_ago = Utc::now() - Duration::days(1);
355 assert_eq!(format_relative_time(&one_day_ago), "1 day ago");
356 }
357
358 #[test]
359 fn relative_time_multiple_days() {
360 let three_days_ago = Utc::now() - Duration::days(3);
361 assert_eq!(format_relative_time(&three_days_ago), "3 days ago");
362 }
363
364 #[test]
365 fn relative_time_one_month() {
366 let one_month_ago = Utc::now() - Duration::days(31);
367 assert_eq!(format_relative_time(&one_month_ago), "1 month ago");
368 }
369
370 #[test]
371 fn relative_time_multiple_months() {
372 let two_months_ago = Utc::now() - Duration::days(65);
373 assert_eq!(format_relative_time(&two_months_ago), "2 months ago");
374 }
375
376 #[test]
381 fn parse_valid_timestamp() {
382 let three_days_ago = (Utc::now() - Duration::days(3)).to_rfc3339();
383 assert_eq!(
384 parse_and_format_relative_time(&three_days_ago),
385 "3 days ago"
386 );
387 }
388
389 #[test]
390 fn parse_invalid_timestamp_returns_original() {
391 let invalid = "not-a-valid-timestamp";
392 assert_eq!(parse_and_format_relative_time(invalid), invalid);
393 }
394
395 #[test]
400 fn is_priority_label_numeric_lowercase() {
401 assert!(is_priority_label("p0"));
402 assert!(is_priority_label("p1"));
403 assert!(is_priority_label("p2"));
404 assert!(is_priority_label("p3"));
405 assert!(is_priority_label("p4"));
406 }
407
408 #[test]
409 fn is_priority_label_numeric_uppercase() {
410 assert!(is_priority_label("P0"));
411 assert!(is_priority_label("P1"));
412 assert!(is_priority_label("P2"));
413 assert!(is_priority_label("P3"));
414 assert!(is_priority_label("P4"));
415 }
416
417 #[test]
418 fn is_priority_label_named_high() {
419 assert!(is_priority_label("priority: high"));
420 assert!(is_priority_label("Priority: High"));
421 assert!(is_priority_label("PRIORITY: HIGH"));
422 }
423
424 #[test]
425 fn is_priority_label_named_medium() {
426 assert!(is_priority_label("priority: medium"));
427 assert!(is_priority_label("Priority: Medium"));
428 assert!(is_priority_label("PRIORITY: MEDIUM"));
429 }
430
431 #[test]
432 fn is_priority_label_named_low() {
433 assert!(is_priority_label("priority: low"));
434 assert!(is_priority_label("Priority: Low"));
435 assert!(is_priority_label("PRIORITY: LOW"));
436 }
437
438 #[test]
439 fn is_priority_label_named_with_extra_spaces() {
440 assert!(is_priority_label("priority: high"));
441 assert!(is_priority_label("priority: high "));
442 assert!(is_priority_label("priority: medium "));
443 }
444
445 #[test]
446 fn is_priority_label_not_priority_invalid_numeric() {
447 assert!(!is_priority_label("p"));
448 assert!(!is_priority_label("p10"));
449 assert!(!is_priority_label("pa"));
450 assert!(!is_priority_label("p-1"));
451 }
452
453 #[test]
454 fn is_priority_label_not_priority_invalid_named() {
455 assert!(!is_priority_label("priority: urgent"));
456 assert!(!is_priority_label("priority: critical"));
457 assert!(!is_priority_label("priority:"));
458 assert!(!is_priority_label("priority: "));
459 }
460
461 #[test]
462 fn is_priority_label_not_priority_other_labels() {
463 assert!(!is_priority_label("bug"));
464 assert!(!is_priority_label("enhancement"));
465 assert!(!is_priority_label("documentation"));
466 assert!(!is_priority_label("help wanted"));
467 assert!(!is_priority_label("good first issue"));
468 }
469
470 #[test]
475 fn parse_git_remote_url_ssh_with_git_suffix() {
476 assert_eq!(
477 parse_git_remote_url("git@github.com:owner/repo.git"),
478 Ok("owner/repo".to_string())
479 );
480 }
481
482 #[test]
483 fn parse_git_remote_url_ssh_without_git_suffix() {
484 assert_eq!(
485 parse_git_remote_url("git@github.com:owner/repo"),
486 Ok("owner/repo".to_string())
487 );
488 }
489
490 #[test]
491 fn parse_git_remote_url_https_with_git_suffix() {
492 assert_eq!(
493 parse_git_remote_url("https://github.com/owner/repo.git"),
494 Ok("owner/repo".to_string())
495 );
496 }
497
498 #[test]
499 fn parse_git_remote_url_https_without_git_suffix() {
500 assert_eq!(
501 parse_git_remote_url("https://github.com/owner/repo"),
502 Ok("owner/repo".to_string())
503 );
504 }
505
506 #[test]
507 fn parse_git_remote_url_invalid() {
508 assert!(parse_git_remote_url("not-a-url").is_err());
509 }
510}