Skip to main content

gitkraft_core/utils/
mod.rs

1//! Utility helpers shared across the crate — OID formatting, relative time, text, etc.
2
3pub mod text;
4pub mod time;
5
6pub use text::truncate_str;
7pub use time::{fmt_oid, relative_time, short_oid, short_oid_str};
8
9/// Build an ascending `Vec<usize>` spanning from `anchor` to `target`
10/// (inclusive), regardless of which is larger.
11///
12/// Used for range-selection in commit and file lists in both GUI and TUI.
13///
14/// # Examples
15/// ```
16/// assert_eq!(gitkraft_core::ascending_range(3, 7), vec![3, 4, 5, 6, 7]);
17/// assert_eq!(gitkraft_core::ascending_range(7, 3), vec![3, 4, 5, 6, 7]);
18/// assert_eq!(gitkraft_core::ascending_range(5, 5), vec![5]);
19/// ```
20pub fn ascending_range(anchor: usize, target: usize) -> Vec<usize> {
21    let (start, end) = if anchor <= target {
22        (anchor, target)
23    } else {
24        (target, anchor)
25    };
26    (start..=end).collect()
27}
28
29/// Next index in a list, wrapping around to 0 after the last element.
30/// Returns 0 if `len` is 0.
31pub fn wrap_next(current: usize, len: usize) -> usize {
32    if len == 0 {
33        return 0;
34    }
35    (current + 1) % len
36}
37
38/// Previous index in a list, wrapping around to `len - 1` from index 0.
39/// Returns 0 if `len` is 0.
40pub fn wrap_prev(current: usize, len: usize) -> usize {
41    if len == 0 {
42        return 0;
43    }
44    if current == 0 {
45        len - 1
46    } else {
47        current - 1
48    }
49}
50
51/// Next index in a list, clamped at `len - 1` (no wrapping).
52/// Returns 0 if `len` is 0.
53pub fn clamp_next(current: usize, len: usize) -> usize {
54    if len == 0 {
55        return 0;
56    }
57    (current + 1).min(len - 1)
58}
59
60/// Human-readable label for a repository path — the last path component,
61/// or `"New Tab"` when `path` is `None` or has no filename.
62pub fn repo_display_name(path: Option<&std::path::Path>) -> String {
63    path.and_then(|p| p.file_name())
64        .map(|n| n.to_string_lossy().into_owned())
65        .unwrap_or_else(|| "New Tab".into())
66}
67
68/// Clamp a selection index after a list has been resized.
69///
70/// - If the list is empty → `None` (nothing to select).
71/// - If no index was selected → `Some(0)` (auto-select first item).
72/// - Otherwise → the existing index clamped to `0..len`.
73pub fn clamp_selection(current: Option<usize>, len: usize) -> Option<usize> {
74    if len == 0 {
75        return None;
76    }
77    Some(current.unwrap_or(0).min(len - 1))
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn ascending_range_low_to_high() {
86        assert_eq!(ascending_range(2, 5), vec![2, 3, 4, 5]);
87    }
88
89    #[test]
90    fn ascending_range_high_to_low_is_still_ascending() {
91        assert_eq!(ascending_range(5, 2), vec![2, 3, 4, 5]);
92    }
93
94    #[test]
95    fn ascending_range_same_returns_single() {
96        assert_eq!(ascending_range(4, 4), vec![4]);
97    }
98
99    // ── wrap_next ─────────────────────────────────────────────────────────
100
101    #[test]
102    fn wrap_next_normal() {
103        assert_eq!(wrap_next(0, 3), 1);
104    }
105
106    #[test]
107    fn wrap_next_wraps_around() {
108        assert_eq!(wrap_next(2, 3), 0);
109    }
110
111    #[test]
112    fn wrap_next_empty_list() {
113        assert_eq!(wrap_next(0, 0), 0);
114    }
115
116    #[test]
117    fn wrap_next_single_element() {
118        assert_eq!(wrap_next(0, 1), 0);
119    }
120
121    // ── wrap_prev ─────────────────────────────────────────────────────────
122
123    #[test]
124    fn wrap_prev_normal() {
125        assert_eq!(wrap_prev(2, 3), 1);
126    }
127
128    #[test]
129    fn wrap_prev_wraps_around() {
130        assert_eq!(wrap_prev(0, 3), 2);
131    }
132
133    #[test]
134    fn wrap_prev_empty_list() {
135        assert_eq!(wrap_prev(0, 0), 0);
136    }
137
138    #[test]
139    fn wrap_prev_single_element() {
140        assert_eq!(wrap_prev(0, 1), 0);
141    }
142
143    // ── clamp_next ────────────────────────────────────────────────────────
144
145    #[test]
146    fn clamp_next_normal() {
147        assert_eq!(clamp_next(0, 3), 1);
148    }
149
150    #[test]
151    fn clamp_next_at_end() {
152        assert_eq!(clamp_next(2, 3), 2);
153    }
154
155    #[test]
156    fn clamp_next_empty_list() {
157        assert_eq!(clamp_next(0, 0), 0);
158    }
159
160    // ── clamp_selection ───────────────────────────────────────────────────
161
162    #[test]
163    fn clamp_selection_empty_list() {
164        assert_eq!(clamp_selection(Some(5), 0), None);
165    }
166
167    #[test]
168    fn clamp_selection_none_becomes_first() {
169        assert_eq!(clamp_selection(None, 3), Some(0));
170    }
171
172    #[test]
173    fn clamp_selection_within_range() {
174        assert_eq!(clamp_selection(Some(1), 3), Some(1));
175    }
176
177    #[test]
178    fn clamp_selection_overflow_clamped() {
179        assert_eq!(clamp_selection(Some(10), 3), Some(2));
180    }
181
182    // ── repo_display_name ─────────────────────────────────────────────────
183
184    #[test]
185    fn repo_display_name_with_path() {
186        let p = std::path::Path::new("/home/user/my-project");
187        assert_eq!(repo_display_name(Some(p)), "my-project");
188    }
189
190    #[test]
191    fn repo_display_name_none() {
192        assert_eq!(repo_display_name(None), "New Tab");
193    }
194
195    #[test]
196    fn repo_display_name_root_path() {
197        let p = std::path::Path::new("/");
198        assert_eq!(repo_display_name(Some(p)), "New Tab");
199    }
200}