1use objects::object::{
7 TimelineBranchReason, TimelineCursorMoveReason, TimelineLabel, TimelineToolCallStatus,
8};
9use repo::TimelineNavigationRecoveryStatus;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ReflogLine {
14 pub source: String,
15 pub reference: String,
16 pub old_oid: String,
17 pub new_oid: String,
18 pub actor: String,
19 pub timestamp: Option<String>,
20 pub message: String,
21}
22
23pub fn parse_reflog_line(source: &str, reference: &str, line: &str) -> Option<ReflogLine> {
25 let (metadata, message) = line.split_once('\t').unwrap_or((line, ""));
26 let mut parts = metadata.split_whitespace();
27 let old_oid = parts.next()?.to_string();
28 let new_oid = parts.next()?.to_string();
29 let mut actor_parts = Vec::new();
30 let mut timestamp = None;
31
32 for part in parts {
33 if part.parse::<i64>().is_ok() {
34 timestamp = Some(part.to_string());
35 break;
36 }
37 actor_parts.push(part);
38 }
39
40 Some(ReflogLine {
41 source: source.to_string(),
42 reference: reference.to_string(),
43 old_oid,
44 new_oid,
45 actor: actor_parts.join(" "),
46 timestamp,
47 message: message.to_string(),
48 })
49}
50
51pub fn short_oid(oid: &str) -> &str {
53 oid.get(..12).unwrap_or(oid)
54}
55
56pub fn summarize_paths(paths: &[String]) -> String {
58 match paths {
59 [] => String::new(),
60 [one] => one.clone(),
61 [one, two] => format!("{one}, {two}"),
62 [one, two, rest @ ..] => format!("{one}, {two} +{}", rest.len()),
63 }
64}
65
66pub fn timeline_label(label: &TimelineLabel) -> &'static str {
68 match label {
69 TimelineLabel::RepoReversible => "repo-reversible",
70 TimelineLabel::ExternalSideEffectsUnknown => "external-side-effects-unknown",
71 TimelineLabel::IgnoredPathTouched => "ignored-path-touched",
72 TimelineLabel::OutsideRepoTouched => "outside-repo-touched",
73 TimelineLabel::PurgeBoundary => "purge-boundary",
74 TimelineLabel::CaptureFailed => "capture-failed",
75 }
76}
77
78pub fn timeline_tool_status(status: &TimelineToolCallStatus) -> &'static str {
80 match status {
81 TimelineToolCallStatus::Succeeded => "succeeded",
82 TimelineToolCallStatus::Failed => "failed",
83 TimelineToolCallStatus::Cancelled => "cancelled",
84 }
85}
86
87pub fn timeline_branch_reason(reason: &TimelineBranchReason) -> &'static str {
89 match reason {
90 TimelineBranchReason::EditFromRewoundCursor => "edit-from-rewound-cursor",
91 TimelineBranchReason::ExplicitFork => "explicit-fork",
92 TimelineBranchReason::Retry => "retry",
93 TimelineBranchReason::FanOut => "fan-out",
94 }
95}
96
97pub fn timeline_cursor_reason(reason: &TimelineCursorMoveReason) -> &'static str {
99 match reason {
100 TimelineCursorMoveReason::SeekToolCall => "seek-tool-call",
101 TimelineCursorMoveReason::Undo => "undo",
102 TimelineCursorMoveReason::Redo => "redo",
103 TimelineCursorMoveReason::Reset => "reset",
104 TimelineCursorMoveReason::AutoAdvance => "auto-advance",
105 }
106}
107
108pub fn timeline_recovery_status(status: TimelineNavigationRecoveryStatus) -> &'static str {
110 match status {
111 TimelineNavigationRecoveryStatus::PendingCursorRecord => "pending-cursor-record",
112 TimelineNavigationRecoveryStatus::Blocked => "blocked",
113 TimelineNavigationRecoveryStatus::AlreadyApplied => "already-applied",
114 }
115}
116
117pub fn session_list_status(is_active: bool) -> &'static str {
119 if is_active { "active" } else { "ended" }
120}
121
122pub fn yes_no(value: bool) -> &'static str {
124 if value { "yes" } else { "no" }
125}
126
127pub fn truncate_with_ellipsis(s: &str, max_len: usize) -> String {
129 if s.len() <= max_len {
130 s.to_string()
131 } else {
132 format!("{}...", &s[..max_len.saturating_sub(3)])
133 }
134}
135
136pub fn fit_author(s: &str, max_len: usize) -> String {
138 if s.len() <= max_len {
139 return s.to_string();
140 }
141 if let Some(angle) = s.find(" <") {
142 let name = &s[..angle];
143 if name.len() <= max_len {
144 return name.to_string();
145 }
146 }
147 truncate_with_ellipsis(s, max_len)
148}
149
150pub fn summarize_context_line(content: &str) -> String {
152 let first_line = content
153 .lines()
154 .find(|line| !line.trim().is_empty())
155 .unwrap_or("");
156 if first_line.len() <= 88 {
157 first_line.to_string()
158 } else {
159 format!("{}...", &first_line[..85])
160 }
161}
162
163pub fn extract_scope_bytes(source: &[u8], range: Option<(u32, u32)>) -> Vec<u8> {
168 let Some((start, end)) = range else {
169 return source.to_vec();
170 };
171 let text = std::str::from_utf8(source).unwrap_or("");
172 let lines: Vec<&str> = text.lines().collect();
173 let start_idx = (start as usize).saturating_sub(1);
174 let end_idx = (end as usize).min(lines.len());
175 if start_idx >= lines.len() {
176 return Vec::new();
177 }
178 lines[start_idx..end_idx].join("\n").into_bytes()
179}
180
181pub fn format_missing_blobs_suffix(missing_shorts: &[String]) -> Option<String> {
183 if missing_shorts.is_empty() {
184 None
185 } else {
186 Some(format!("missing blobs: {}", missing_shorts.join(", ")))
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 #[test]
195 fn parse_reflog_line_with_tab_message() {
196 let line = "abc123 def456 Alice <a@b.com> 1700000000 +0000\tcommit: hello";
197 let parsed = parse_reflog_line("checkout", "HEAD", line).expect("parse");
198 assert_eq!(parsed.old_oid, "abc123");
199 assert_eq!(parsed.new_oid, "def456");
200 assert_eq!(parsed.actor, "Alice <a@b.com>");
201 assert_eq!(parsed.timestamp.as_deref(), Some("1700000000"));
202 assert_eq!(parsed.message, "commit: hello");
203 assert_eq!(parsed.source, "checkout");
204 assert_eq!(parsed.reference, "HEAD");
205 }
206
207 #[test]
208 fn parse_reflog_line_rejects_incomplete() {
209 assert!(parse_reflog_line("s", "r", "onlyone").is_none());
210 }
211
212 #[test]
213 fn short_oid_and_summarize_paths() {
214 assert_eq!(short_oid("0123456789abcdef"), "0123456789ab");
215 assert_eq!(short_oid("short"), "short");
216 assert_eq!(summarize_paths(&[]), "");
217 assert_eq!(summarize_paths(&["a".into()]), "a");
218 assert_eq!(summarize_paths(&["a".into(), "b".into()]), "a, b");
219 assert_eq!(
220 summarize_paths(&["a".into(), "b".into(), "c".into(), "d".into()]),
221 "a, b +2"
222 );
223 }
224
225 #[test]
226 fn timeline_and_session_labels() {
227 assert_eq!(
228 timeline_label(&TimelineLabel::RepoReversible),
229 "repo-reversible"
230 );
231 assert_eq!(
232 timeline_tool_status(&TimelineToolCallStatus::Failed),
233 "failed"
234 );
235 assert_eq!(
236 timeline_branch_reason(&TimelineBranchReason::FanOut),
237 "fan-out"
238 );
239 assert_eq!(
240 timeline_cursor_reason(&TimelineCursorMoveReason::Undo),
241 "undo"
242 );
243 assert_eq!(
244 timeline_recovery_status(TimelineNavigationRecoveryStatus::Blocked),
245 "blocked"
246 );
247 assert_eq!(session_list_status(true), "active");
248 assert_eq!(session_list_status(false), "ended");
249 assert_eq!(yes_no(true), "yes");
250 assert_eq!(truncate_with_ellipsis("abcdef", 5), "ab...");
251 assert_eq!(
252 fit_author("Ada Lovelace <ada@really.long.example.com>", 12),
253 "Ada Lovelace"
254 );
255 assert_eq!(summarize_context_line("\n hello world\n"), " hello world");
256 let src = b"a\nb\nc\n";
257 assert_eq!(extract_scope_bytes(src, None), src.to_vec());
258 assert_eq!(extract_scope_bytes(src, Some((2, 3))), b"b\nc".to_vec());
259 assert!(extract_scope_bytes(src, Some((10, 12))).is_empty());
260 assert_eq!(
261 format_missing_blobs_suffix(&["aa".into(), "bb".into()]).as_deref(),
262 Some("missing blobs: aa, bb")
263 );
264 assert!(format_missing_blobs_suffix(&[]).is_none());
265 }
266}