1use objects::object::{TimelineBranchReason, TimelineToolCallStatus};
13use repo::{
14 TimelineBranchId, TimelineMaterializationRecoveryStatus, TimelineMaterializeMode,
15 TimelineMaterializeStatus, TimelineNativeToolKey, TimelineSeekBranchConstraint,
16 TimelineSeekSelector, TimelineStepId,
17};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum TimelinePlanError {
26 InvalidToolStatus { raw: String },
28 InvalidBranchReason { raw: String },
30 InvalidMaterializeMode { raw: String },
32 ThreadRequired,
34 TargetRequired,
36 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
71pub 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
87pub 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
100pub 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
111pub 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
126pub 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct TimelineSelection {
162 pub thread: String,
163 pub selector: TimelineSeekSelector,
164 pub branch_constraint: Option<TimelineSeekBranchConstraint>,
165}
166
167pub 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}