Skip to main content

verbs/
log_plan.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pure log/reflog helpers: parse, summarize, short ids, timeline labels.
3//!
4//! FS traversal and styled render stay in CLI.
5
6use objects::object::{
7    TimelineBranchReason, TimelineCursorMoveReason, TimelineLabel, TimelineToolCallStatus,
8};
9use repo::TimelineNavigationRecoveryStatus;
10
11/// Parsed reflog line (git-style: `old new actor... timestamp\tmessage`).
12#[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
23/// Parse one reflog line. Returns `None` when old/new oids are missing.
24pub 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
51/// First 12 hex chars of an object id (or the whole string if shorter).
52pub fn short_oid(oid: &str) -> &str {
53    oid.get(..12).unwrap_or(oid)
54}
55
56/// Compact path list for timeline one-liners.
57pub 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
66/// Stable timeline label string for machine/text output.
67pub 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
78/// Tool-call status label.
79pub 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
87/// Branch reason label.
88pub 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
97/// Cursor move reason label.
98pub 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
108/// Navigation recovery status label.
109pub 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
117/// Session list row status (active vs ended).
118pub fn session_list_status(is_active: bool) -> &'static str {
119    if is_active { "active" } else { "ended" }
120}
121
122/// yes/no for compact timeline fields.
123pub fn yes_no(value: bool) -> &'static str {
124    if value { "yes" } else { "no" }
125}
126
127/// Truncate a string with an ellipsis when longer than `max_len`.
128pub 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
136/// Fit `Name <email>` attribution into `max_len` without dropping the name.
137pub 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
150/// First non-empty line of content, truncated for blame/context snippets.
151pub 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
163/// Extract bytes for a 1-indexed inclusive line range from source content.
164///
165/// If `range` is `None`, returns the full source. Invalid/out-of-range
166/// starts yield empty bytes.
167pub 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
181/// Format missing blob shorts for fetch error augmentation.
182pub 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}