1use std::collections::BTreeMap;
8use std::path::Path;
9
10use anyhow::{Context, Result, bail};
11use chrono::{DateTime, Utc};
12use shipper_types::{
13 ErrorClass, EventType, ExecutionState, PackageProgress, PackageState, PublishEvent,
14 ReconciliationOutcome, Registry,
15};
16
17use crate::runtime::execution::pkg_key;
18
19use super::{events, execution_state};
20
21#[derive(Debug, Clone)]
23pub struct StateRebuildOptions {
24 pub registry: Registry,
25 pub fallback_plan_id: Option<String>,
26}
27
28impl StateRebuildOptions {
29 pub fn new(registry: Registry) -> Self {
30 Self {
31 registry,
32 fallback_plan_id: None,
33 }
34 }
35
36 pub fn with_fallback_plan_id(mut self, plan_id: impl Into<String>) -> Self {
37 self.fallback_plan_id = Some(plan_id.into());
38 self
39 }
40}
41
42pub fn rebuild_state_from_events(
49 events_path: &Path,
50 options: StateRebuildOptions,
51) -> Result<ExecutionState> {
52 let log = events::EventLog::read_from_file(events_path).with_context(|| {
53 format!(
54 "failed to read event log for state rebuild: {}",
55 events_path.display()
56 )
57 })?;
58 let events = log.all_events();
59 let now = Utc::now();
60 let created_at = events.first().map(|event| event.timestamp).unwrap_or(now);
61 let updated_at = events.last().map(|event| event.timestamp).unwrap_or(now);
62 let mut plan_id = options.fallback_plan_id;
63 let mut packages = BTreeMap::new();
64
65 for event in events {
66 apply_event(event, &mut plan_id, &mut packages);
67 }
68
69 let Some(plan_id) = plan_id else {
70 bail!(
71 "cannot rebuild state from {}: no plan_created event and no fallback plan_id supplied",
72 events_path.display()
73 );
74 };
75
76 Ok(ExecutionState {
77 state_version: execution_state::CURRENT_STATE_VERSION.to_string(),
78 plan_id,
79 registry: options.registry,
80 created_at,
81 updated_at,
82 attempt_history: Vec::new(),
83 packages,
84 })
85}
86
87pub fn rebuild_state_file_from_events(
89 state_dir: &Path,
90 options: StateRebuildOptions,
91) -> Result<ExecutionState> {
92 let events_path = events::events_path(state_dir);
93 let state = rebuild_state_from_events(&events_path, options)?;
94 execution_state::save_state(state_dir, &state)?;
95 Ok(state)
96}
97
98fn apply_event(
99 event: &PublishEvent,
100 plan_id: &mut Option<String>,
101 packages: &mut BTreeMap<String, PackageProgress>,
102) {
103 match &event.event_type {
104 EventType::PlanCreated {
105 plan_id: event_plan_id,
106 ..
107 } => {
108 *plan_id = Some(event_plan_id.clone());
109 }
110 EventType::PackageStarted { name, version } => {
111 let key = pkg_key(name, version);
112 let progress = ensure_package(packages, &key, name, version, event.timestamp);
113 progress.state = PackageState::Pending;
114 progress.last_updated_at = event.timestamp;
115 }
116 EventType::PackageAttempted { attempt, .. } => {
117 if let Some(progress) = ensure_event_package(packages, event, event.timestamp) {
118 progress.attempts = progress.attempts.max(*attempt);
119 progress.last_updated_at = event.timestamp;
120 }
121 }
122 EventType::PackagePublished { .. } => {
123 if let Some(progress) = ensure_event_package(packages, event, event.timestamp) {
124 progress.state = PackageState::Published;
125 progress.last_updated_at = event.timestamp;
126 }
127 }
128 EventType::PackageSkipped { reason } => {
129 if let Some(progress) = ensure_event_package(packages, event, event.timestamp) {
130 progress.state = PackageState::Skipped {
131 reason: reason.clone(),
132 };
133 progress.last_updated_at = event.timestamp;
134 }
135 }
136 EventType::PackageFailed { class, message } => {
137 if let Some(progress) = ensure_event_package(packages, event, event.timestamp) {
138 progress.state = match class {
139 ErrorClass::Ambiguous => PackageState::Ambiguous {
140 message: message.clone(),
141 },
142 _ => PackageState::Failed {
143 class: class.clone(),
144 message: message.clone(),
145 },
146 };
147 progress.last_updated_at = event.timestamp;
148 }
149 }
150 EventType::PublishReconciled { outcome } => {
151 if let Some(progress) = ensure_event_package(packages, event, event.timestamp) {
152 progress.state = match outcome {
153 ReconciliationOutcome::Published { .. } => PackageState::Published,
154 ReconciliationOutcome::NotPublished { .. } => PackageState::Pending,
155 ReconciliationOutcome::StillUnknown { reason, .. } => PackageState::Ambiguous {
156 message: reason.clone(),
157 },
158 };
159 progress.last_updated_at = event.timestamp;
160 }
161 }
162 _ => {}
163 }
164}
165
166fn ensure_event_package<'a>(
167 packages: &'a mut BTreeMap<String, PackageProgress>,
168 event: &PublishEvent,
169 timestamp: DateTime<Utc>,
170) -> Option<&'a mut PackageProgress> {
171 split_package_label(&event.package)
172 .map(|(name, version)| ensure_package(packages, &event.package, name, version, timestamp))
173}
174
175fn ensure_package<'a>(
176 packages: &'a mut BTreeMap<String, PackageProgress>,
177 key: &str,
178 name: &str,
179 version: &str,
180 timestamp: DateTime<Utc>,
181) -> &'a mut PackageProgress {
182 packages
183 .entry(key.to_string())
184 .or_insert_with(|| PackageProgress {
185 name: name.to_string(),
186 version: version.to_string(),
187 attempts: 0,
188 state: PackageState::Pending,
189 last_updated_at: timestamp,
190 })
191}
192
193fn split_package_label(package: &str) -> Option<(&str, &str)> {
194 if package == "all" || package.is_empty() {
195 return None;
196 }
197 package.rsplit_once('@')
198}
199
200#[cfg(test)]
201mod tests {
202 use chrono::{TimeZone, Utc};
203 use shipper_types::{ErrorClass, ReadinessMethod, ReconciliationOutcome};
204 use tempfile::tempdir;
205
206 use super::*;
207
208 fn ts(second: u32) -> DateTime<Utc> {
209 Utc.with_ymd_and_hms(2026, 5, 17, 12, 0, second)
210 .single()
211 .expect("valid timestamp")
212 }
213
214 fn event(second: u32, package: &str, event_type: EventType) -> PublishEvent {
215 PublishEvent {
216 timestamp: ts(second),
217 event_type,
218 package: package.to_string(),
219 }
220 }
221
222 fn options() -> StateRebuildOptions {
223 StateRebuildOptions::new(Registry::crates_io())
224 }
225
226 fn write_events(path: &Path, events: Vec<PublishEvent>) {
227 let mut log = events::EventLog::new();
228 for event in events {
229 log.record(event);
230 }
231 log.write_to_file(path).expect("write events");
232 }
233
234 #[test]
235 fn rebuild_missing_events_uses_fallback_plan_id() {
236 let td = tempdir().expect("tempdir");
237
238 let state = rebuild_state_from_events(
239 &td.path().join("events.jsonl"),
240 options().with_fallback_plan_id("fallback-plan"),
241 )
242 .expect("rebuild");
243
244 assert_eq!(state.plan_id, "fallback-plan");
245 assert!(state.packages.is_empty());
246 assert!(state.attempt_history.is_empty());
247 }
248
249 #[test]
250 fn rebuild_requires_plan_id_source() {
251 let td = tempdir().expect("tempdir");
252
253 let err = rebuild_state_from_events(&td.path().join("events.jsonl"), options())
254 .expect_err("missing plan id should fail");
255
256 assert!(err.to_string().contains("no plan_created event"));
257 }
258
259 #[test]
260 fn rebuild_package_started_creates_pending_progress() {
261 let td = tempdir().expect("tempdir");
262 let events_path = td.path().join("events.jsonl");
263 write_events(
264 &events_path,
265 vec![
266 event(
267 0,
268 "all",
269 EventType::PlanCreated {
270 plan_id: "plan-123".to_string(),
271 package_count: 1,
272 },
273 ),
274 event(
275 1,
276 "demo@0.1.0",
277 EventType::PackageStarted {
278 name: "demo".to_string(),
279 version: "0.1.0".to_string(),
280 },
281 ),
282 ],
283 );
284
285 let state = rebuild_state_from_events(&events_path, options()).expect("rebuild");
286 let progress = state.packages.get("demo@0.1.0").expect("package");
287
288 assert_eq!(state.plan_id, "plan-123");
289 assert_eq!(progress.name, "demo");
290 assert_eq!(progress.version, "0.1.0");
291 assert_eq!(progress.attempts, 0);
292 assert_eq!(progress.state, PackageState::Pending);
293 assert_eq!(progress.last_updated_at, ts(1));
294 }
295
296 #[test]
297 fn rebuild_attempted_updates_attempt_count() {
298 let td = tempdir().expect("tempdir");
299 let events_path = td.path().join("events.jsonl");
300 write_events(
301 &events_path,
302 vec![
303 event(
304 0,
305 "all",
306 EventType::PlanCreated {
307 plan_id: "plan-123".to_string(),
308 package_count: 1,
309 },
310 ),
311 event(
312 1,
313 "demo@0.1.0",
314 EventType::PackageAttempted {
315 attempt: 1,
316 command: "cargo publish".to_string(),
317 },
318 ),
319 event(
320 2,
321 "demo@0.1.0",
322 EventType::PackageAttempted {
323 attempt: 3,
324 command: "cargo publish".to_string(),
325 },
326 ),
327 ],
328 );
329
330 let state = rebuild_state_from_events(&events_path, options()).expect("rebuild");
331 let progress = state.packages.get("demo@0.1.0").expect("package");
332
333 assert_eq!(progress.attempts, 3);
334 assert_eq!(progress.state, PackageState::Pending);
335 assert_eq!(progress.last_updated_at, ts(2));
336 }
337
338 #[test]
339 fn rebuild_terminal_events_project_package_state() {
340 let td = tempdir().expect("tempdir");
341 let events_path = td.path().join("events.jsonl");
342 write_events(
343 &events_path,
344 vec![
345 event(
346 0,
347 "all",
348 EventType::PlanCreated {
349 plan_id: "plan-123".to_string(),
350 package_count: 3,
351 },
352 ),
353 event(
354 1,
355 "published@1.0.0",
356 EventType::PackagePublished { duration_ms: 10 },
357 ),
358 event(
359 2,
360 "skipped@1.0.0",
361 EventType::PackageSkipped {
362 reason: "already present".to_string(),
363 },
364 ),
365 event(
366 3,
367 "failed@1.0.0",
368 EventType::PackageFailed {
369 class: ErrorClass::Permanent,
370 message: "auth failed".to_string(),
371 },
372 ),
373 ],
374 );
375
376 let state = rebuild_state_from_events(&events_path, options()).expect("rebuild");
377
378 assert_eq!(
379 state.packages["published@1.0.0"].state,
380 PackageState::Published
381 );
382 assert_eq!(
383 state.packages["skipped@1.0.0"].state,
384 PackageState::Skipped {
385 reason: "already present".to_string()
386 }
387 );
388 assert_eq!(
389 state.packages["failed@1.0.0"].state,
390 PackageState::Failed {
391 class: ErrorClass::Permanent,
392 message: "auth failed".to_string()
393 }
394 );
395 }
396
397 #[test]
398 fn rebuild_reconciliation_outcomes_override_ambiguous_failure() {
399 let td = tempdir().expect("tempdir");
400 let events_path = td.path().join("events.jsonl");
401 write_events(
402 &events_path,
403 vec![
404 event(
405 0,
406 "all",
407 EventType::PlanCreated {
408 plan_id: "plan-123".to_string(),
409 package_count: 3,
410 },
411 ),
412 event(
413 1,
414 "published@1.0.0",
415 EventType::PackageFailed {
416 class: ErrorClass::Ambiguous,
417 message: "cargo output ambiguous".to_string(),
418 },
419 ),
420 event(
421 2,
422 "published@1.0.0",
423 EventType::PublishReconciled {
424 outcome: ReconciliationOutcome::Published {
425 attempts: 1,
426 elapsed_ms: 10,
427 },
428 },
429 ),
430 event(
431 3,
432 "retry@1.0.0",
433 EventType::PackageFailed {
434 class: ErrorClass::Ambiguous,
435 message: "cargo output ambiguous".to_string(),
436 },
437 ),
438 event(
439 4,
440 "retry@1.0.0",
441 EventType::PublishReconciled {
442 outcome: ReconciliationOutcome::NotPublished {
443 attempts: 1,
444 elapsed_ms: 10,
445 },
446 },
447 ),
448 event(
449 5,
450 "unknown@1.0.0",
451 EventType::PublishReconciled {
452 outcome: ReconciliationOutcome::StillUnknown {
453 attempts: 1,
454 elapsed_ms: 10,
455 reason: "registry unavailable".to_string(),
456 },
457 },
458 ),
459 ],
460 );
461
462 let state = rebuild_state_from_events(&events_path, options()).expect("rebuild");
463
464 assert_eq!(
465 state.packages["published@1.0.0"].state,
466 PackageState::Published
467 );
468 assert_eq!(state.packages["retry@1.0.0"].state, PackageState::Pending);
469 assert_eq!(
470 state.packages["unknown@1.0.0"].state,
471 PackageState::Ambiguous {
472 message: "registry unavailable".to_string()
473 }
474 );
475 }
476
477 #[test]
478 fn rebuild_preserves_event_order_last_terminal_wins() {
479 let td = tempdir().expect("tempdir");
480 let events_path = td.path().join("events.jsonl");
481 write_events(
482 &events_path,
483 vec![
484 event(
485 0,
486 "all",
487 EventType::PlanCreated {
488 plan_id: "plan-123".to_string(),
489 package_count: 1,
490 },
491 ),
492 event(
493 1,
494 "demo@0.1.0",
495 EventType::PackageFailed {
496 class: ErrorClass::Retryable,
497 message: "timeout".to_string(),
498 },
499 ),
500 event(
501 2,
502 "demo@0.1.0",
503 EventType::PackagePublished { duration_ms: 100 },
504 ),
505 ],
506 );
507
508 let state = rebuild_state_from_events(&events_path, options()).expect("rebuild");
509
510 assert_eq!(state.packages["demo@0.1.0"].state, PackageState::Published);
511 assert_eq!(state.updated_at, ts(2));
512 }
513
514 #[test]
515 fn rebuild_state_file_from_events_writes_state_json() {
516 let td = tempdir().expect("tempdir");
517 let state_dir = td.path().join(".shipper");
518 let events_path = events::events_path(&state_dir);
519 write_events(
520 &events_path,
521 vec![
522 event(
523 0,
524 "all",
525 EventType::PlanCreated {
526 plan_id: "plan-123".to_string(),
527 package_count: 1,
528 },
529 ),
530 event(
531 1,
532 "demo@0.1.0",
533 EventType::PackagePublished { duration_ms: 100 },
534 ),
535 ],
536 );
537
538 let rebuilt = rebuild_state_file_from_events(&state_dir, options()).expect("rebuild");
539 let loaded = execution_state::load_state(&state_dir)
540 .expect("load")
541 .expect("state");
542
543 assert_eq!(rebuilt.plan_id, "plan-123");
544 assert_eq!(loaded.packages["demo@0.1.0"].state, PackageState::Published);
545 }
546
547 #[test]
548 fn rebuild_ignores_events_without_package_labels() {
549 let td = tempdir().expect("tempdir");
550 let events_path = td.path().join("events.jsonl");
551 write_events(
552 &events_path,
553 vec![
554 event(
555 0,
556 "all",
557 EventType::PlanCreated {
558 plan_id: "plan-123".to_string(),
559 package_count: 1,
560 },
561 ),
562 event(1, "all", EventType::ExecutionStarted),
563 event(
564 2,
565 "",
566 EventType::PublishReconciled {
567 outcome: ReconciliationOutcome::Published {
568 attempts: 1,
569 elapsed_ms: 1,
570 },
571 },
572 ),
573 event(
574 3,
575 "demo@0.1.0",
576 EventType::PublishReconciling {
577 method: ReadinessMethod::Both,
578 },
579 ),
580 ],
581 );
582
583 let state = rebuild_state_from_events(&events_path, options()).expect("rebuild");
584
585 assert!(state.packages.is_empty());
586 }
587}