aptu_core/
utils.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Text utility functions for Aptu.
4//!
5//! Provides reusable text formatting utilities for truncation and relative time display.
6//! These functions are used by CLI, and will be available to iOS and MCP consumers.
7
8use chrono::{DateTime, Utc};
9use regex::Regex;
10use std::process::Command;
11
12/// Truncates text to a maximum length with a custom suffix.
13///
14/// Uses character count (not byte count) to safely handle multi-byte UTF-8.
15/// The suffix is included in the max length calculation.
16///
17/// # Examples
18///
19/// ```
20/// use aptu_core::utils::truncate_with_suffix;
21///
22/// let text = "This is a very long string that needs truncation";
23/// let result = truncate_with_suffix(text, 20, "... [more]");
24/// assert!(result.ends_with("... [more]"));
25/// assert!(result.chars().count() <= 20);
26/// ```
27#[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/// Truncates text to a maximum length with default ellipsis suffix "...".
41///
42/// Uses character count (not byte count) to safely handle multi-byte UTF-8.
43///
44/// # Examples
45///
46/// ```
47/// use aptu_core::utils::truncate;
48///
49/// // Short text unchanged
50/// assert_eq!(truncate("Hello", 10), "Hello");
51///
52/// // Long text truncated with ellipsis
53/// let long = "This is a very long title that exceeds the limit";
54/// let result = truncate(long, 20);
55/// assert!(result.ends_with("..."));
56/// assert!(result.chars().count() <= 20);
57/// ```
58#[must_use]
59pub fn truncate(text: &str, max_len: usize) -> String {
60    truncate_with_suffix(text, max_len, "...")
61}
62
63/// Formats a `DateTime<Utc>` as relative time (e.g., "3 days ago").
64///
65/// # Examples
66///
67/// ```
68/// use chrono::{Utc, Duration};
69/// use aptu_core::utils::format_relative_time;
70///
71/// let now = Utc::now();
72/// assert_eq!(format_relative_time(&now), "just now");
73///
74/// let yesterday = now - Duration::days(1);
75/// assert_eq!(format_relative_time(&yesterday), "1 day ago");
76/// ```
77#[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/// Parses an ISO 8601 timestamp and formats it as relative time.
109///
110/// Returns the original string if parsing fails.
111///
112/// # Examples
113///
114/// ```
115/// use aptu_core::utils::parse_and_format_relative_time;
116///
117/// // Valid timestamp
118/// let result = parse_and_format_relative_time("2024-01-01T00:00:00Z");
119/// assert!(result.contains("ago") || result.contains("months"));
120///
121/// // Invalid timestamp returns original
122/// let invalid = parse_and_format_relative_time("not-a-date");
123/// assert_eq!(invalid, "not-a-date");
124/// ```
125#[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/// Check if a label is a priority label (p0-p4 or priority: high/medium/low).
134///
135/// Recognizes two priority label patterns:
136/// - Numeric: `p0`, `p1`, `p2`, `p3`, `p4` (case-insensitive)
137/// - Named: `priority: high`, `priority: medium`, `priority: low` (case-insensitive)
138///
139/// # Examples
140///
141/// ```
142/// use aptu_core::utils::is_priority_label;
143///
144/// // Numeric priority labels
145/// assert!(is_priority_label("p0"));
146/// assert!(is_priority_label("P3"));
147/// assert!(is_priority_label("p4"));
148///
149/// // Named priority labels
150/// assert!(is_priority_label("priority: high"));
151/// assert!(is_priority_label("Priority: Medium"));
152/// assert!(is_priority_label("PRIORITY: LOW"));
153///
154/// // Non-priority labels
155/// assert!(!is_priority_label("bug"));
156/// assert!(!is_priority_label("enhancement"));
157/// assert!(!is_priority_label("priority: urgent"));
158/// ```
159#[must_use]
160pub fn is_priority_label(label: &str) -> bool {
161    let lower = label.to_lowercase();
162
163    // Check for p[0-9] pattern (e.g., p0, p1, p2, p3, p4)
164    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    // Check for priority: prefix (e.g., priority: high, priority: medium, priority: low)
172    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
180/// Parses a git remote URL to extract owner/repo.
181///
182/// Supports SSH (git@github.com:owner/repo.git) and HTTPS
183/// (<https://github.com/owner/repo.git>) formats.
184///
185/// # Examples
186///
187/// ```
188/// use aptu_core::utils::parse_git_remote_url;
189///
190/// assert_eq!(parse_git_remote_url("git@github.com:owner/repo.git"), Ok("owner/repo".to_string()));
191/// assert_eq!(parse_git_remote_url("https://github.com/owner/repo.git"), Ok("owner/repo".to_string()));
192/// assert_eq!(parse_git_remote_url("git@github.com:owner/repo"), Ok("owner/repo".to_string()));
193/// ```
194pub fn parse_git_remote_url(url: &str) -> Result<String, String> {
195    // Parse SSH format: git@github.com:owner/repo.git
196    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    // Parse HTTPS format: https://github.com/owner/repo.git
202    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    // Try generic regex pattern for other git hosts
208    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
220/// Infers the GitHub repository (owner/repo) from the local git config.
221///
222/// Runs `git config --get remote.origin.url` and parses the result.
223///
224/// # Errors
225///
226/// Returns an error if:
227/// - Not in a git repository
228/// - No origin remote is configured
229/// - Origin URL cannot be parsed
230///
231/// # Examples
232///
233/// ```no_run
234/// use aptu_core::utils::infer_repo_from_git;
235///
236/// match infer_repo_from_git() {
237///     Ok(repo) => println!("Found repo: {}", repo),
238///     Err(e) => eprintln!("Error: {}", e),
239/// }
240/// ```
241pub 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    // ========================================================================
269    // truncate() tests
270    // ========================================================================
271
272    #[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        // Emoji and multibyte characters should be handled correctly
295        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    // ========================================================================
302    // truncate_with_suffix() tests
303    // ========================================================================
304
305    #[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        // 49 chars, should not be truncated
327        assert_eq!(result, body);
328    }
329
330    // ========================================================================
331    // format_relative_time() tests
332    // ========================================================================
333
334    #[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    // ========================================================================
377    // parse_and_format_relative_time() tests
378    // ========================================================================
379
380    #[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    // ========================================================================
396    // is_priority_label() tests
397    // ========================================================================
398
399    #[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    // ========================================================================
471    // parse_git_remote_url() tests
472    // ========================================================================
473
474    #[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}