Skip to main content

shipper_core/state/
rebuild.rs

1//! Rebuild `state.json` from the authoritative event log.
2//!
3//! `events.jsonl` is the source of truth. This module projects that log back
4//! into an [`ExecutionState`] so a damaged or missing `state.json` can be
5//! recovered without guessing from CLI output.
6
7use 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/// Inputs that cannot be recovered from `events.jsonl` alone.
22#[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
42/// Project an [`ExecutionState`] from an event log.
43///
44/// The registry is supplied by the caller because publish events currently
45/// record the plan id but not the full registry definition. If the log contains
46/// no `plan_created` event, `fallback_plan_id` is used; otherwise this returns
47/// an error.
48pub 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
87/// Rebuild and write `<state_dir>/state.json` from `<state_dir>/events.jsonl`.
88pub 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}