use super::*;
pub(super) fn changes_from_records(store: &CheckpointStore, read: LedgerRead) -> ChangesReadModel {
let mut by_turn: BTreeMap<u64, Vec<ChangeEntry>> = BTreeMap::new();
for record in read.records {
let event = record.event;
let visible_path = redacted_relative_path(&event.status, event.relative_path.clone());
let (classification, rewindable, unavailable_reason) = match event.status {
SnapshotCaptureStatus::Captured => {
let available = event
.pre
.as_ref()
.into_iter()
.chain(event.post.as_ref())
.all(|blob| store.blob_path(&blob.sha256).exists());
let classification = match (&event.pre, &event.post) {
(None, Some(_)) => ChangeClassification::Created,
(Some(_), Some(_)) => ChangeClassification::Modified,
(Some(_), None) => ChangeClassification::Deleted,
(None, None) => ChangeClassification::NonRewindable,
};
(
classification,
available,
(!available).then_some("missing checkpoint blob".to_string()),
)
}
SnapshotCaptureStatus::Excluded { reason } => (
ChangeClassification::NonRewindable,
false,
Some(format!("excluded: {reason:?}")),
),
SnapshotCaptureStatus::Oversized { bytes, max_bytes } => (
ChangeClassification::NonRewindable,
false,
Some(format!("oversized: {bytes} > {max_bytes}")),
),
SnapshotCaptureStatus::Unavailable { reason } => {
(ChangeClassification::NonRewindable, false, Some(reason))
}
};
by_turn
.entry(event.user_turn)
.or_default()
.push(ChangeEntry {
tool: event.tool,
relative_path: visible_path,
classification,
rewindable,
unavailable_reason,
});
}
ChangesReadModel {
turns: by_turn
.into_iter()
.map(|(user_turn, mut changes)| {
changes.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
ChangeTurn { user_turn, changes }
})
.collect(),
diagnostics: read.diagnostics,
}
}
pub(super) fn plan_rewind(
store: &CheckpointStore,
session_id: &str,
cwd: &Path,
target_turn: u64,
) -> RestorePlan {
let read = store.read_records(session_id);
plan_rewind_from_records(store, session_id, cwd, target_turn, &read)
}
pub(super) fn plan_rewind_from_records(
store: &CheckpointStore,
session_id: &str,
cwd: &Path,
target_turn: u64,
read: &LedgerRead,
) -> RestorePlan {
let latest_turn = read
.records
.iter()
.map(|record| record.event.user_turn)
.max();
let selected = read
.records
.iter()
.filter(|record| record.event.user_turn >= target_turn)
.cloned()
.collect::<Vec<_>>();
let mut by_path: BTreeMap<PathBuf, Vec<SnapshotLedgerRecord>> = BTreeMap::new();
for record in selected {
by_path
.entry(record.event.relative_path.clone())
.or_default()
.push(record);
}
let (canonical_cwd, root) = open_rewind_root(cwd);
let mut operations = Vec::new();
for (relative_path, mut records) in by_path {
records.sort_by_key(|record| record.event.user_turn);
if let Err(reason) = safe_rewind_target_path(store, &canonical_cwd, &relative_path) {
operations.push(RestoreOperation {
relative_path: PathBuf::from(REDACTED_PATH),
kind: RestoreOperationKind::SkipUnavailable {
reason: reason.to_string(),
},
});
continue;
}
let first = &records.first().expect("group non-empty").event;
let last = &records.last().expect("group non-empty").event;
if !records
.iter()
.all(|record| record.event.status == SnapshotCaptureStatus::Captured)
{
operations.push(RestoreOperation {
relative_path,
kind: RestoreOperationKind::SkipUnavailable {
reason: "non-rewindable snapshot".to_string(),
},
});
continue;
}
let expected_current = last.post.clone();
let current = match root.as_ref() {
Some(root) => {
match target_snapshot_for_plan(root, &relative_path, expected_current.is_none()) {
Ok(current) => current,
Err(_) => {
operations.push(RestoreOperation {
relative_path,
kind: RestoreOperationKind::SkipUnavailable {
reason: "hash check failed".to_string(),
},
});
continue;
}
}
}
None => {
operations.push(RestoreOperation {
relative_path,
kind: RestoreOperationKind::SkipUnavailable {
reason: "hash check failed".to_string(),
},
});
continue;
}
};
if let Some(expected) = expected_current.as_ref() {
if current.as_ref().map(|snapshot| snapshot.hash.as_str())
!= Some(expected.sha256.as_str())
{
operations.push(RestoreOperation {
relative_path,
kind: RestoreOperationKind::SkipConflict {
reason: "current file hash differs from checkpoint post-image".to_string(),
},
});
continue;
}
} else if current.is_some() {
operations.push(RestoreOperation {
relative_path,
kind: RestoreOperationKind::SkipConflict {
reason: "expected deleted file exists".to_string(),
},
});
continue;
}
let kind = match (&first.pre, expected_current) {
(Some(pre), Some(expected_current)) => {
if !store.blob_path(&pre.sha256).exists() {
RestoreOperationKind::SkipUnavailable {
reason: "missing pre-image blob".to_string(),
}
} else {
RestoreOperationKind::Restore {
pre: pre.clone(),
expected_current,
}
}
}
(None, Some(expected_current)) => {
RestoreOperationKind::DeleteCreated { expected_current }
}
(Some(pre), None) => {
if store.blob_path(&pre.sha256).exists() {
RestoreOperationKind::ResurrectDeleted { pre: pre.clone() }
} else {
RestoreOperationKind::SkipUnavailable {
reason: "missing pre-image blob".to_string(),
}
}
}
(None, None) => RestoreOperationKind::SkipUnavailable {
reason: "missing pre and post images".to_string(),
},
};
operations.push(RestoreOperation {
relative_path,
kind,
});
}
operations.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
RestorePlan {
session_id: session_id.to_string(),
target_turn,
latest_turn,
operations,
diagnostics: read.diagnostics.clone(),
}
}