Skip to main content

verbs/
timeline_plan.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pure timeline CLI planning: parse/label helpers and target selection.
3//!
4//! Owns string ↔ enum mapping for timeline action flags and pure validation of
5//! seek/fork/reset target selectors. Repository I/O, store access, recovery
6//! advice rendering, and terminal output stay CLI-owned.
7//!
8//! Label helpers that already live in [`crate::log_plan`] are not duplicated
9//! here; callers should reuse those for tool status, branch reason, cursor
10//! reason, navigation recovery, and timeline labels.
11
12use objects::object::{TimelineBranchReason, TimelineToolCallStatus};
13use repo::{
14    TimelineBranchId, TimelineMaterializationRecoveryStatus, TimelineMaterializeMode,
15    TimelineMaterializeStatus, TimelineNativeToolKey, TimelineSeekBranchConstraint,
16    TimelineSeekSelector, TimelineStepId,
17};
18
19// ---------------------------------------------------------------------------
20// Errors
21// ---------------------------------------------------------------------------
22
23/// Failures from pure timeline parse / target planning.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum TimelinePlanError {
26    /// `--status` was not one of the known tool-call statuses.
27    InvalidToolStatus { raw: String },
28    /// `--reason` was not one of the known branch reasons.
29    InvalidBranchReason { raw: String },
30    /// `--mode` was not one of the known materialize modes.
31    InvalidMaterializeMode { raw: String },
32    /// Timeline thread name was empty.
33    ThreadRequired,
34    /// Zero or more than one of `--step` / `--tool-call` / `--undo` / `--redo` / `--current`.
35    TargetRequired,
36    /// `--tool-call` was set without a non-empty `--harness`.
37    ToolCallHarnessRequired,
38}
39
40impl std::fmt::Display for TimelinePlanError {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            Self::InvalidToolStatus { raw } => {
44                write!(
45                    f,
46                    "--status expects succeeded, failed, or cancelled, got '{raw}'"
47                )
48            }
49            Self::InvalidBranchReason { raw } => write!(
50                f,
51                "--reason expects explicit-fork, edit-from-rewound-cursor, retry, or fan-out, got '{raw}'"
52            ),
53            Self::InvalidMaterializeMode { raw } => write!(
54                f,
55                "--mode expects fail-if-dirty or capture-current-then-seek, got '{raw}'"
56            ),
57            Self::ThreadRequired => write!(f, "--thread is required for timeline navigation"),
58            Self::TargetRequired => write!(
59                f,
60                "select exactly one timeline target: --step, --tool-call, --undo, --redo, or --current"
61            ),
62            Self::ToolCallHarnessRequired => {
63                write!(f, "--harness is required for --tool-call timeline targets")
64            }
65        }
66    }
67}
68
69impl std::error::Error for TimelinePlanError {}
70
71// ---------------------------------------------------------------------------
72// Parse helpers
73// ---------------------------------------------------------------------------
74
75/// Parse a tool-call finish status string (`succeeded` / `failed` / `cancelled`).
76pub fn parse_tool_status(value: &str) -> Result<TimelineToolCallStatus, TimelinePlanError> {
77    match value {
78        "succeeded" => Ok(TimelineToolCallStatus::Succeeded),
79        "failed" => Ok(TimelineToolCallStatus::Failed),
80        "cancelled" => Ok(TimelineToolCallStatus::Cancelled),
81        other => Err(TimelinePlanError::InvalidToolStatus {
82            raw: other.to_string(),
83        }),
84    }
85}
86
87/// Parse a timeline branch reason string.
88pub fn parse_branch_reason(value: &str) -> Result<TimelineBranchReason, TimelinePlanError> {
89    match value {
90        "explicit-fork" => Ok(TimelineBranchReason::ExplicitFork),
91        "edit-from-rewound-cursor" => Ok(TimelineBranchReason::EditFromRewoundCursor),
92        "retry" => Ok(TimelineBranchReason::Retry),
93        "fan-out" => Ok(TimelineBranchReason::FanOut),
94        other => Err(TimelinePlanError::InvalidBranchReason {
95            raw: other.to_string(),
96        }),
97    }
98}
99
100/// Parse a materialization mode string.
101pub fn parse_materialize_mode(value: &str) -> Result<TimelineMaterializeMode, TimelinePlanError> {
102    match value {
103        "fail-if-dirty" => Ok(TimelineMaterializeMode::FailIfDirty),
104        "capture-current-then-seek" => Ok(TimelineMaterializeMode::CaptureCurrentThenSeek),
105        other => Err(TimelinePlanError::InvalidMaterializeMode {
106            raw: other.to_string(),
107        }),
108    }
109}
110
111// ---------------------------------------------------------------------------
112// Label helpers (not already in log_plan)
113// ---------------------------------------------------------------------------
114
115/// Materialize attempt status label for machine/text output.
116pub fn timeline_materialize_status(status: &TimelineMaterializeStatus) -> &'static str {
117    match status {
118        TimelineMaterializeStatus::Materialized => "materialized",
119        TimelineMaterializeStatus::AlreadyAtTarget => "already-at-target",
120        TimelineMaterializeStatus::Refused => "refused",
121        TimelineMaterializeStatus::Unsupported => "unsupported",
122        TimelineMaterializeStatus::RecoveryBlocked => "recovery-blocked",
123    }
124}
125
126/// Materialization recovery status label (distinct from navigation recovery).
127pub fn timeline_materialization_recovery_status(
128    status: &TimelineMaterializationRecoveryStatus,
129) -> &'static str {
130    match status {
131        TimelineMaterializationRecoveryStatus::NoPending => "no-pending",
132        TimelineMaterializationRecoveryStatus::CursorRecorded => "cursor-recorded",
133        TimelineMaterializationRecoveryStatus::AlreadyApplied => "already-applied",
134        TimelineMaterializationRecoveryStatus::Blocked => "blocked",
135    }
136}
137
138// ---------------------------------------------------------------------------
139// Target selection
140// ---------------------------------------------------------------------------
141
142/// Caller-supplied timeline target flags for pure seek/fork/reset planning.
143///
144/// Field names mirror the CLI `TimelineTargetArgs` surface.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct TimelineTargetOptions {
147    pub thread: String,
148    pub from_branch: Option<String>,
149    pub step: Option<String>,
150    pub tool_call: Option<String>,
151    pub harness: String,
152    pub session: Option<String>,
153    pub message: Option<String>,
154    pub undo: bool,
155    pub redo: bool,
156    pub current: bool,
157}
158
159/// Pure result of selecting a timeline seek/fork/reset target.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct TimelineSelection {
162    pub thread: String,
163    pub selector: TimelineSeekSelector,
164    pub branch_constraint: Option<TimelineSeekBranchConstraint>,
165}
166
167/// Plan a timeline target selector from pure flag inputs (no I/O).
168pub fn plan_timeline_target(
169    opts: &TimelineTargetOptions,
170) -> Result<TimelineSelection, TimelinePlanError> {
171    if opts.thread.trim().is_empty() {
172        return Err(TimelinePlanError::ThreadRequired);
173    }
174
175    let selected = opts.step.is_some() as u8
176        + opts.tool_call.is_some() as u8
177        + opts.undo as u8
178        + opts.redo as u8
179        + opts.current as u8;
180    if selected != 1 {
181        return Err(TimelinePlanError::TargetRequired);
182    }
183
184    let branch = opts
185        .from_branch
186        .as_ref()
187        .map(|branch| TimelineBranchId::new(branch.clone()));
188    let (selector, branch_constraint) = if let Some(step_id) = &opts.step {
189        (
190            TimelineSeekSelector::StepId(TimelineStepId::new(step_id.clone())),
191            branch.map(TimelineSeekBranchConstraint::Target),
192        )
193    } else if let Some(tool_call_id) = &opts.tool_call {
194        if opts.harness.trim().is_empty() {
195            return Err(TimelinePlanError::ToolCallHarnessRequired);
196        }
197        (
198            TimelineSeekSelector::NativeToolCall(TimelineNativeToolKey {
199                harness: opts.harness.clone(),
200                session_id: opts.session.clone(),
201                message_id: opts.message.clone(),
202                tool_call_id: tool_call_id.clone(),
203            }),
204            None,
205        )
206    } else if opts.undo {
207        (
208            TimelineSeekSelector::Undo,
209            branch.map(TimelineSeekBranchConstraint::Current),
210        )
211    } else if opts.redo {
212        (
213            TimelineSeekSelector::Redo,
214            branch.map(TimelineSeekBranchConstraint::Current),
215        )
216    } else {
217        (
218            TimelineSeekSelector::CurrentCursor,
219            branch.map(TimelineSeekBranchConstraint::Current),
220        )
221    };
222
223    Ok(TimelineSelection {
224        thread: opts.thread.clone(),
225        selector,
226        branch_constraint,
227    })
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::log_plan::{timeline_branch_reason, timeline_tool_status};
234
235    fn target() -> TimelineTargetOptions {
236        TimelineTargetOptions {
237            thread: "main".to_string(),
238            from_branch: None,
239            step: None,
240            tool_call: None,
241            harness: "opencode".to_string(),
242            session: None,
243            message: None,
244            undo: false,
245            redo: false,
246            current: false,
247        }
248    }
249
250    #[test]
251    fn parse_tool_status_round_trips() {
252        for (raw, expected) in [
253            ("succeeded", TimelineToolCallStatus::Succeeded),
254            ("failed", TimelineToolCallStatus::Failed),
255            ("cancelled", TimelineToolCallStatus::Cancelled),
256        ] {
257            let parsed = parse_tool_status(raw).expect("parse");
258            assert_eq!(parsed, expected);
259            assert_eq!(timeline_tool_status(&parsed), raw);
260        }
261        assert!(matches!(
262            parse_tool_status("nope"),
263            Err(TimelinePlanError::InvalidToolStatus { .. })
264        ));
265    }
266
267    #[test]
268    fn parse_branch_reason_round_trips() {
269        for (raw, expected) in [
270            ("explicit-fork", TimelineBranchReason::ExplicitFork),
271            (
272                "edit-from-rewound-cursor",
273                TimelineBranchReason::EditFromRewoundCursor,
274            ),
275            ("retry", TimelineBranchReason::Retry),
276            ("fan-out", TimelineBranchReason::FanOut),
277        ] {
278            let parsed = parse_branch_reason(raw).expect("parse");
279            assert_eq!(parsed, expected);
280            assert_eq!(timeline_branch_reason(&parsed), raw);
281        }
282        assert!(matches!(
283            parse_branch_reason("side-quest"),
284            Err(TimelinePlanError::InvalidBranchReason { .. })
285        ));
286    }
287
288    #[test]
289    fn parse_materialize_mode_and_labels() {
290        assert_eq!(
291            parse_materialize_mode("fail-if-dirty").unwrap(),
292            TimelineMaterializeMode::FailIfDirty
293        );
294        assert_eq!(
295            parse_materialize_mode("capture-current-then-seek").unwrap(),
296            TimelineMaterializeMode::CaptureCurrentThenSeek
297        );
298        assert!(matches!(
299            parse_materialize_mode("auto"),
300            Err(TimelinePlanError::InvalidMaterializeMode { .. })
301        ));
302
303        assert_eq!(
304            timeline_materialize_status(&TimelineMaterializeStatus::Materialized),
305            "materialized"
306        );
307        assert_eq!(
308            timeline_materialize_status(&TimelineMaterializeStatus::AlreadyAtTarget),
309            "already-at-target"
310        );
311        assert_eq!(
312            timeline_materialize_status(&TimelineMaterializeStatus::Refused),
313            "refused"
314        );
315        assert_eq!(
316            timeline_materialize_status(&TimelineMaterializeStatus::Unsupported),
317            "unsupported"
318        );
319        assert_eq!(
320            timeline_materialize_status(&TimelineMaterializeStatus::RecoveryBlocked),
321            "recovery-blocked"
322        );
323
324        assert_eq!(
325            timeline_materialization_recovery_status(
326                &TimelineMaterializationRecoveryStatus::NoPending
327            ),
328            "no-pending"
329        );
330        assert_eq!(
331            timeline_materialization_recovery_status(
332                &TimelineMaterializationRecoveryStatus::CursorRecorded
333            ),
334            "cursor-recorded"
335        );
336        assert_eq!(
337            timeline_materialization_recovery_status(
338                &TimelineMaterializationRecoveryStatus::AlreadyApplied
339            ),
340            "already-applied"
341        );
342        assert_eq!(
343            timeline_materialization_recovery_status(
344                &TimelineMaterializationRecoveryStatus::Blocked
345            ),
346            "blocked"
347        );
348    }
349
350    #[test]
351    fn plan_timeline_target_requires_one_target() {
352        assert_eq!(
353            plan_timeline_target(&target()),
354            Err(TimelinePlanError::TargetRequired)
355        );
356
357        let mut opts = target();
358        opts.step = Some("tls-one".to_string());
359        opts.tool_call = Some("call-1".to_string());
360        assert_eq!(
361            plan_timeline_target(&opts),
362            Err(TimelinePlanError::TargetRequired)
363        );
364    }
365
366    #[test]
367    fn plan_timeline_target_builds_native_tool_call_selector() {
368        let mut opts = target();
369        opts.tool_call = Some("call-1".to_string());
370        opts.session = Some("session-1".to_string());
371
372        let selection = plan_timeline_target(&opts).unwrap();
373        let TimelineSeekSelector::NativeToolCall(native) = selection.selector else {
374            panic!("expected native tool call selector");
375        };
376        assert_eq!(native.harness, "opencode");
377        assert_eq!(native.session_id.as_deref(), Some("session-1"));
378        assert_eq!(native.tool_call_id, "call-1");
379        assert!(selection.branch_constraint.is_none());
380        assert_eq!(selection.thread, "main");
381    }
382
383    #[test]
384    fn plan_timeline_target_step_and_undo_constraints() {
385        let mut opts = target();
386        opts.step = Some("tls-x".to_string());
387        opts.from_branch = Some("tlb-a".to_string());
388        let selection = plan_timeline_target(&opts).unwrap();
389        assert!(matches!(
390            selection.selector,
391            TimelineSeekSelector::StepId(ref id) if id.as_str() == "tls-x"
392        ));
393        assert!(matches!(
394            selection.branch_constraint,
395            Some(TimelineSeekBranchConstraint::Target(_))
396        ));
397
398        let mut opts = target();
399        opts.undo = true;
400        opts.from_branch = Some("tlb-b".to_string());
401        let selection = plan_timeline_target(&opts).unwrap();
402        assert!(matches!(selection.selector, TimelineSeekSelector::Undo));
403        assert!(matches!(
404            selection.branch_constraint,
405            Some(TimelineSeekBranchConstraint::Current(_))
406        ));
407    }
408
409    #[test]
410    fn plan_timeline_target_rejects_empty_thread_and_harness() {
411        let mut opts = target();
412        opts.thread = "  ".to_string();
413        opts.current = true;
414        assert_eq!(
415            plan_timeline_target(&opts),
416            Err(TimelinePlanError::ThreadRequired)
417        );
418
419        let mut opts = target();
420        opts.tool_call = Some("call-1".to_string());
421        opts.harness = String::new();
422        assert_eq!(
423            plan_timeline_target(&opts),
424            Err(TimelinePlanError::ToolCallHarnessRequired)
425        );
426    }
427}